@agentsdock/server 1.0.11-beta.1 → 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 +1474 -318
- package/server/agentsdock_jobs.py +2 -2
- package/server/codex_auth.py +48 -0
- package/server/codex_history_repair.py +180 -15
- 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 +25 -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 +195 -15
- package/server/shared_chat_analytics_web.py +5 -0
- package/server/title_generation.py +60 -1
- package/server/transcript_reader.py +20 -8
- package/server/update_runner.py +4 -2
package/server/agent_server.py
CHANGED
|
@@ -126,6 +126,7 @@ from provider_commands import (
|
|
|
126
126
|
claude_provider_command_inventory,
|
|
127
127
|
codex_provider_command_inventory,
|
|
128
128
|
empty_provider_command_inventory,
|
|
129
|
+
gemini_provider_skill_inventory,
|
|
129
130
|
opencode_provider_skill_inventory,
|
|
130
131
|
validate_opencode_provider_skill_record,
|
|
131
132
|
)
|
|
@@ -208,7 +209,8 @@ BACKEND_CLAUDE = "claude"
|
|
|
208
209
|
BACKEND_CODEX = "codex"
|
|
209
210
|
BACKEND_CURSOR = "cursor"
|
|
210
211
|
BACKEND_OPENCODE = "opencode"
|
|
211
|
-
|
|
212
|
+
BACKEND_GEMINI = "gemini"
|
|
213
|
+
VALID_BACKENDS = {BACKEND_CLAUDE, BACKEND_CODEX, BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}
|
|
212
214
|
CODEX_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
|
|
213
215
|
CODEX_EFFORT_ALIASES = {
|
|
214
216
|
"extra high": "xhigh",
|
|
@@ -662,6 +664,7 @@ def codex_effective_thread_config(sess: dict[str, Any]) -> dict[str, Any]:
|
|
|
662
664
|
CODEX_GOALS_ENABLED = read_codex_goals_enabled()
|
|
663
665
|
|
|
664
666
|
CODEX_FALLBACK_MODELS = (
|
|
667
|
+
("gpt-6-luna", "GPT-6-Luna"),
|
|
665
668
|
("gpt-5.6-sol", "GPT-5.6-Sol"),
|
|
666
669
|
("gpt-5.6-terra", "GPT-5.6-Terra"),
|
|
667
670
|
("gpt-5.6-luna", "GPT-5.6-Luna"),
|
|
@@ -672,6 +675,7 @@ CODEX_FALLBACK_MODELS = (
|
|
|
672
675
|
CODEX_FALLBACK_EFFORTS = ("low", "medium", "high", "xhigh", "max", "ultra")
|
|
673
676
|
CODEX_EFFORT_ORDER = ("none", "minimal", *CODEX_FALLBACK_EFFORTS)
|
|
674
677
|
CODEX_FALLBACK_MODEL_EFFORTS = {
|
|
678
|
+
"gpt-6-luna": ("low", "medium", "high", "xhigh", "max"),
|
|
675
679
|
"gpt-5.6-sol": ("low", "medium", "high", "xhigh", "max", "ultra"),
|
|
676
680
|
"gpt-5.6-terra": ("low", "medium", "high", "xhigh", "max", "ultra"),
|
|
677
681
|
"gpt-5.6-luna": ("low", "medium", "high", "xhigh", "max"),
|
|
@@ -1301,6 +1305,7 @@ MAX_LOCAL_SESSION_SCAN_FILES = 10_000
|
|
|
1301
1305
|
# Retained text/metadata budget. Opaque native media/tool bytes are streamed.
|
|
1302
1306
|
# Private timeline/index readers still use this as their raw record bound.
|
|
1303
1307
|
MAX_LOCAL_TRANSCRIPT_LINE_BYTES = 32 * 1024 * 1024
|
|
1308
|
+
HISTORY_SYNC_BATCH_BYTES = 16 * 1024 * 1024
|
|
1304
1309
|
MAX_LOCAL_TRANSCRIPT_SCAN_LINES = 250_000
|
|
1305
1310
|
MAX_CODEX_SESSION_INDEX_BYTES = 16 * 1024 * 1024
|
|
1306
1311
|
MAX_CODEX_SESSION_INDEX_LINES = 100_000
|
|
@@ -6317,6 +6322,7 @@ class CreateSessionRequest(BaseModel):
|
|
|
6317
6322
|
# Same three names as Cursor, but "default" means OpenCode's own
|
|
6318
6323
|
# permissions, which do allow shell.
|
|
6319
6324
|
opencode_permission_mode: Literal["default", "full_access", "plan"] | None = None
|
|
6325
|
+
gemini_permission_mode: Literal["default", "autoEdit", "yolo", "plan"] | None = None
|
|
6320
6326
|
|
|
6321
6327
|
|
|
6322
6328
|
MAX_BULK_IMPORT_ITEMS = 25
|
|
@@ -6328,9 +6334,10 @@ PROVIDER_SESSION_IDENTIFIER_RE = re.compile(
|
|
|
6328
6334
|
|
|
6329
6335
|
class BulkImportSessionItem(BaseModel):
|
|
6330
6336
|
provider_session_id: str = Field(min_length=1, max_length=256)
|
|
6331
|
-
backend: Literal["claude", "codex", "cursor"]
|
|
6337
|
+
backend: Literal["claude", "codex", "cursor", "gemini"]
|
|
6332
6338
|
cwd: str | None = Field(default=None, max_length=MAX_WORKSPACE_PATH_CHARS)
|
|
6333
6339
|
title: str | None = Field(default=None, max_length=MAX_LOCAL_SESSION_LABEL_CHARS)
|
|
6340
|
+
provider_connection: Literal["custom"] | None = None
|
|
6334
6341
|
|
|
6335
6342
|
@field_validator("provider_session_id")
|
|
6336
6343
|
@classmethod
|
|
@@ -6354,6 +6361,7 @@ class BulkImportSessionsRequest(BaseModel):
|
|
|
6354
6361
|
|
|
6355
6362
|
|
|
6356
6363
|
class UpdateSessionRequest(BaseModel):
|
|
6364
|
+
gemini_connect_api: bool | None = Field(default=None, strict=True)
|
|
6357
6365
|
title: str | None = None
|
|
6358
6366
|
auto_title_enabled: bool | None = None
|
|
6359
6367
|
folder: str | None = None
|
|
@@ -6382,6 +6390,7 @@ class UpdateSessionRequest(BaseModel):
|
|
|
6382
6390
|
provider_jobs_access: Literal["full", "read_only", "blocked"] | None = None
|
|
6383
6391
|
cursor_permission_mode: Literal["default", "full_access", "plan"] | None = None
|
|
6384
6392
|
opencode_permission_mode: Literal["default", "full_access", "plan"] | None = None
|
|
6393
|
+
gemini_permission_mode: Literal["default", "autoEdit", "yolo", "plan"] | None = None
|
|
6385
6394
|
|
|
6386
6395
|
|
|
6387
6396
|
SESSION_LIFECYCLE_UPDATE_FIELDS = frozenset({
|
|
@@ -6400,6 +6409,7 @@ SESSION_LIFECYCLE_UPDATE_FIELDS = frozenset({
|
|
|
6400
6409
|
"provider_jobs_access",
|
|
6401
6410
|
"cursor_permission_mode",
|
|
6402
6411
|
"opencode_permission_mode",
|
|
6412
|
+
"gemini_permission_mode",
|
|
6403
6413
|
"archived",
|
|
6404
6414
|
})
|
|
6405
6415
|
|
|
@@ -6549,12 +6559,25 @@ class EmergencyAlertRequest(BaseModel):
|
|
|
6549
6559
|
class AcknowledgeEmergencyRequest(BaseModel):
|
|
6550
6560
|
model_config = {"extra": "forbid"}
|
|
6551
6561
|
|
|
6552
|
-
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,
|
|
6553
6570
|
min_length=8,
|
|
6554
6571
|
max_length=80,
|
|
6555
6572
|
pattern=r"^emergency_[0-9a-f]{32}$",
|
|
6556
6573
|
)
|
|
6557
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
|
+
|
|
6558
6581
|
|
|
6559
6582
|
class ChatReference(BaseModel):
|
|
6560
6583
|
model_config = {"extra": "forbid"}
|
|
@@ -6723,6 +6746,7 @@ class ProviderCommandSelectionUnavailable(HTTPException):
|
|
|
6723
6746
|
|
|
6724
6747
|
class TurnRequest(BaseModel):
|
|
6725
6748
|
prompt: str
|
|
6749
|
+
client_request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{8,128}$")
|
|
6726
6750
|
# Server-derived attribution only; the guest router accepts neither field
|
|
6727
6751
|
# from its JSON body and never accepts arbitrary TurnRequest controls.
|
|
6728
6752
|
shared_chat_id: str | None = Field(default=None, pattern=r"^interactive_[a-f0-9]{32}$")
|
|
@@ -7035,6 +7059,10 @@ def normalize_runtime_effort(backend: str, effort: Any, *, strict: bool = False)
|
|
|
7035
7059
|
clean = str(effort or "").strip().lower()
|
|
7036
7060
|
if not clean:
|
|
7037
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
|
|
7038
7066
|
if str(backend or "").strip().lower() != BACKEND_CODEX:
|
|
7039
7067
|
return clean
|
|
7040
7068
|
|
|
@@ -7180,7 +7208,7 @@ def preview_session_runtime_update(
|
|
|
7180
7208
|
|
|
7181
7209
|
current_connection = sess.get("provider_connection") or "default"
|
|
7182
7210
|
connection = (patch.get("provider_connection") or "default") if "provider_connection" in patch else "default" if backend_changed else current_connection
|
|
7183
|
-
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}:
|
|
7184
7212
|
raise HTTPException(400, "This backend does not support that API connection.")
|
|
7185
7213
|
connection_changed = current_connection != connection
|
|
7186
7214
|
if connection_changed and session_backend_locked(sess):
|
|
@@ -7192,11 +7220,21 @@ def preview_session_runtime_update(
|
|
|
7192
7220
|
if "model" in patch
|
|
7193
7221
|
else None if backend_changed or provider_changed else sess.get("model")
|
|
7194
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))
|
|
7195
7226
|
prospective_effort = (
|
|
7196
7227
|
patch.get("effort")
|
|
7197
7228
|
if "effort" in patch
|
|
7198
7229
|
else None if backend_changed or provider_changed else sess.get("effort")
|
|
7199
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
|
+
)
|
|
7200
7238
|
normalized_effort = (
|
|
7201
7239
|
normalize_runtime_effort(prospective_backend, prospective_effort, strict="effort" in patch)
|
|
7202
7240
|
if prospective_provider == "custom" else
|
|
@@ -7215,7 +7253,7 @@ def preview_session_runtime_update(
|
|
|
7215
7253
|
preview["model"] = prospective_model
|
|
7216
7254
|
preview["effort"] = normalized_effort
|
|
7217
7255
|
preview["provider_connection"] = connection
|
|
7218
|
-
if connection_changed or connection == "default":
|
|
7256
|
+
if backend_changed or connection_changed or connection == "default":
|
|
7219
7257
|
preview.pop("provider_connection_revision", None)
|
|
7220
7258
|
if connection == "custom":
|
|
7221
7259
|
selected_connection = PROVIDER_CONNECTION_STORE.bind(preview)
|
|
@@ -7237,6 +7275,7 @@ def preview_session_runtime_update(
|
|
|
7237
7275
|
BACKEND_CODEX: "codex_thread_id",
|
|
7238
7276
|
BACKEND_CURSOR: "cursor_session_id",
|
|
7239
7277
|
BACKEND_OPENCODE: "opencode_session_id",
|
|
7278
|
+
BACKEND_GEMINI: "gemini_session_id",
|
|
7240
7279
|
}[prospective_backend]
|
|
7241
7280
|
preview["session_id"] = sess.get(provider_id_field)
|
|
7242
7281
|
return preview
|
|
@@ -9628,35 +9667,66 @@ def normalized_emergency_alert(value: Any) -> dict[str, Any] | None:
|
|
|
9628
9667
|
}
|
|
9629
9668
|
|
|
9630
9669
|
|
|
9631
|
-
def reversed_jsonl_events(
|
|
9632
|
-
|
|
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
|
+
"""
|
|
9633
9679
|
|
|
9634
9680
|
if not path.exists():
|
|
9635
9681
|
return
|
|
9636
9682
|
chunk_size = 256 * 1024
|
|
9637
9683
|
with path.open("rb") as source:
|
|
9638
9684
|
source.seek(0, os.SEEK_END)
|
|
9639
|
-
position = source.tell()
|
|
9640
|
-
|
|
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
|
+
|
|
9641
9709
|
while position > 0:
|
|
9642
9710
|
read_size = min(chunk_size, position)
|
|
9643
9711
|
position -= read_size
|
|
9644
9712
|
source.seek(position)
|
|
9645
|
-
block = source.read(read_size)
|
|
9646
|
-
|
|
9647
|
-
|
|
9648
|
-
|
|
9649
|
-
|
|
9650
|
-
|
|
9651
|
-
with suppress(Exception):
|
|
9652
|
-
event = json.loads(raw_line.decode("utf-8", "replace"))
|
|
9653
|
-
if isinstance(event, dict):
|
|
9654
|
-
yield event
|
|
9655
|
-
if remainder.strip():
|
|
9656
|
-
with suppress(Exception):
|
|
9657
|
-
event = json.loads(remainder.decode("utf-8", "replace"))
|
|
9658
|
-
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:
|
|
9659
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
|
+
})
|
|
9660
9730
|
|
|
9661
9731
|
|
|
9662
9732
|
def emergency_lifecycle_events_after(
|
|
@@ -9667,7 +9737,9 @@ def emergency_lifecycle_events_after(
|
|
|
9667
9737
|
|
|
9668
9738
|
newest_seq = max(0, int(after_seq or 0))
|
|
9669
9739
|
events: list[dict[str, Any]] = []
|
|
9670
|
-
for event in reversed_jsonl_events(
|
|
9740
|
+
for event in reversed_jsonl_events(
|
|
9741
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
9742
|
+
):
|
|
9671
9743
|
seq = int(event.get("seq") or 0)
|
|
9672
9744
|
newest_seq = max(newest_seq, seq)
|
|
9673
9745
|
if seq <= after_seq:
|
|
@@ -10158,6 +10230,9 @@ class SessionStore:
|
|
|
10158
10230
|
if reconcile_session_emergency_alerts(session_id, sess):
|
|
10159
10231
|
runtime_changed = True
|
|
10160
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
|
|
10161
10236
|
claude_permission_mode = effective_claude_permission_mode(sess)
|
|
10162
10237
|
if sess.get("claude_permission_mode") != claude_permission_mode:
|
|
10163
10238
|
sess["claude_permission_mode"] = claude_permission_mode
|
|
@@ -10522,13 +10597,25 @@ class SessionStore:
|
|
|
10522
10597
|
if initializing_fork and parent_id and req.provider_connection == "custom":
|
|
10523
10598
|
parent = self.sessions.get(parent_id) or {}
|
|
10524
10599
|
runtime_source.update({name: parent.get(name) for name in ("provider_connection", "provider_connection_revision")})
|
|
10525
|
-
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)):
|
|
10526
10601
|
raise HTTPException(409, "Import native conversations with their native login. Create a new chat for a custom API.")
|
|
10527
10602
|
runtime = preview_session_runtime_update(runtime_source, {
|
|
10528
10603
|
"codex_provider": req.codex_provider, "provider_connection": req.provider_connection, "model": req.model, "effort": req.effort,
|
|
10529
10604
|
})
|
|
10530
10605
|
model, effort = runtime["model"], runtime["effort"]
|
|
10531
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())
|
|
10532
10619
|
if backend == BACKEND_OPENCODE:
|
|
10533
10620
|
session_cwd = validated_opencode_cwd(
|
|
10534
10621
|
session_cwd,
|
|
@@ -10565,6 +10652,7 @@ class SessionStore:
|
|
|
10565
10652
|
claude_session_id if backend == BACKEND_CLAUDE
|
|
10566
10653
|
else codex_thread_id if backend == BACKEND_CODEX
|
|
10567
10654
|
else cursor_session_id if backend == BACKEND_CURSOR
|
|
10655
|
+
else provider_id if backend == BACKEND_GEMINI
|
|
10568
10656
|
else opencode_session_id
|
|
10569
10657
|
)
|
|
10570
10658
|
# A Claude chat can park an inactive Codex identity for later backend
|
|
@@ -10631,6 +10719,9 @@ class SessionStore:
|
|
|
10631
10719
|
"claude_session_id": claude_session_id,
|
|
10632
10720
|
"codex_thread_id": codex_thread_id,
|
|
10633
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,
|
|
10634
10725
|
"opencode_session_id": opencode_session_id,
|
|
10635
10726
|
"opencode_session_cwd": (
|
|
10636
10727
|
session_cwd
|
|
@@ -10650,6 +10741,7 @@ class SessionStore:
|
|
|
10650
10741
|
"opencode_permission_mode": (
|
|
10651
10742
|
req.opencode_permission_mode or OPENCODE_DEFAULT_PERMISSION_MODE
|
|
10652
10743
|
),
|
|
10744
|
+
"gemini_permission_mode": req.gemini_permission_mode or "default",
|
|
10653
10745
|
"codex_approval_policy": (
|
|
10654
10746
|
req.codex_approval_policy or CODEX_DEFAULT_APPROVAL_POLICY
|
|
10655
10747
|
),
|
|
@@ -10846,6 +10938,7 @@ class SessionStore:
|
|
|
10846
10938
|
if patch.get("auto_title_enabled") is not None:
|
|
10847
10939
|
sess["auto_title_enabled"] = bool(patch["auto_title_enabled"])
|
|
10848
10940
|
for key, default, allowed in (
|
|
10941
|
+
("gemini_permission_mode", "default", {"default", "autoEdit", "yolo", "plan"}),
|
|
10849
10942
|
(
|
|
10850
10943
|
"claude_permission_mode",
|
|
10851
10944
|
CLAUDE_DEFAULT_PERMISSION_MODE,
|
|
@@ -10921,14 +11014,15 @@ class SessionStore:
|
|
|
10921
11014
|
sess["subagent_limit"] = patch["subagent_limit"]
|
|
10922
11015
|
if "system_prompt" in patch:
|
|
10923
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)
|
|
10924
11019
|
for key in ("model", "effort"):
|
|
10925
11020
|
if key in patch:
|
|
10926
11021
|
value = patch[key]
|
|
10927
11022
|
if key == "effort":
|
|
10928
11023
|
sess[key] = normalized_prospective_effort
|
|
10929
11024
|
else:
|
|
10930
|
-
|
|
10931
|
-
sess[key] = clean or None
|
|
11025
|
+
sess[key] = runtime_preview["model"]
|
|
10932
11026
|
if reset_implicit_incompatible_effort:
|
|
10933
11027
|
# Older clients update the model and effort in separate
|
|
10934
11028
|
# requests. Let that model change succeed while clearing a
|
|
@@ -11048,7 +11142,8 @@ class SessionStore:
|
|
|
11048
11142
|
}
|
|
11049
11143
|
sess.update(title=title, _title_source=source, _title_auto_value=title)
|
|
11050
11144
|
if source == "prompt" and isinstance(prompt_seed, str):
|
|
11051
|
-
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])
|
|
11052
11147
|
elif source in {"provider", "generated"}:
|
|
11053
11148
|
sess.pop("_title_seed", None)
|
|
11054
11149
|
sess["updated_at"] = now_iso()
|
|
@@ -11185,9 +11280,18 @@ class SessionStore:
|
|
|
11185
11280
|
sid: str,
|
|
11186
11281
|
event: dict[str, Any],
|
|
11187
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
|
+
)
|
|
11188
11292
|
async with self._lock:
|
|
11189
11293
|
sess = self.sessions.get(sid)
|
|
11190
|
-
if not sess:
|
|
11294
|
+
if not sess or sess is not snapshot:
|
|
11191
11295
|
raise HTTPException(status_code=404, detail="session not found")
|
|
11192
11296
|
previous_present = "_emergency_alerts" in sess
|
|
11193
11297
|
previous = sess.get("_emergency_alerts")
|
|
@@ -11197,11 +11301,10 @@ class SessionStore:
|
|
|
11197
11301
|
0,
|
|
11198
11302
|
int(sess.get("_emergency_reconciled_through_seq") or 0),
|
|
11199
11303
|
)
|
|
11200
|
-
|
|
11201
|
-
|
|
11202
|
-
|
|
11203
|
-
|
|
11204
|
-
)
|
|
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]
|
|
11205
11308
|
event_seq = int(event.get("seq") or 0)
|
|
11206
11309
|
if (
|
|
11207
11310
|
event_seq > previous_reconciled_through
|
|
@@ -11372,6 +11475,9 @@ class SessionStore:
|
|
|
11372
11475
|
sess.pop("cursor_instruction_version", None)
|
|
11373
11476
|
elif backend == BACKEND_OPENCODE:
|
|
11374
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
|
|
11375
11481
|
if backend == BACKEND_CLAUDE and cwd:
|
|
11376
11482
|
sess["claude_session_cwd"] = cwd
|
|
11377
11483
|
if backend == BACKEND_OPENCODE and cwd:
|
|
@@ -13064,6 +13170,9 @@ class JobStore:
|
|
|
13064
13170
|
client_capabilities=[
|
|
13065
13171
|
ASYNC_ROUTE_V1_CLIENT_CAPABILITY,
|
|
13066
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 []),
|
|
13067
13176
|
],
|
|
13068
13177
|
chat_references=chat_references,
|
|
13069
13178
|
team_references=team_references,
|
|
@@ -14083,6 +14192,8 @@ RUN_NOW_TURNS: dict[str, dict[str, Any]] = {}
|
|
|
14083
14192
|
# that the scan has not discovered yet. A completed failed task intentionally
|
|
14084
14193
|
# remains installed and keeps admission fail-closed.
|
|
14085
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()
|
|
14086
14197
|
QUEUE_RECOVERY_ADMISSION_WAIT_SECONDS = 2.0
|
|
14087
14198
|
STEERING_SESSIONS: set[str] = set()
|
|
14088
14199
|
# A non-native Force Send keeps the steering fence while the interrupted
|
|
@@ -16543,6 +16654,7 @@ HANDOFF_DIGEST_FINALIZING: set[str] = set()
|
|
|
16543
16654
|
RUNTIME_DIAGNOSTICS: dict[str, dict[str, Any]] = {}
|
|
16544
16655
|
RUNTIME_DIAGNOSTICS_LOCK = threading.RLock()
|
|
16545
16656
|
RUNTIME_DIAGNOSTIC_GENERATIONS: dict[str, int] = {}
|
|
16657
|
+
CODEX_MODEL_MIGRATION_TARGET = "gpt-6-luna"
|
|
16546
16658
|
ABANDONED_FORK_PROVIDER_THREADS: set[str] = set()
|
|
16547
16659
|
ABANDONED_FORK_PROVIDER_THREADS_LOCK = asyncio.Lock()
|
|
16548
16660
|
|
|
@@ -17347,6 +17459,7 @@ def should_bump_session_updated_at(event_type: str, event: dict[str, Any]) -> bo
|
|
|
17347
17459
|
|
|
17348
17460
|
|
|
17349
17461
|
async def update_session_event_metadata(session_id: str, event: dict[str, Any]) -> None:
|
|
17462
|
+
QUEUE_CHECKPOINT_DIRTY.add(session_id)
|
|
17350
17463
|
sess = STORE.sessions.get(session_id)
|
|
17351
17464
|
if not sess:
|
|
17352
17465
|
return
|
|
@@ -17474,7 +17587,22 @@ async def update_session_event_metadata(session_id: str, event: dict[str, Any])
|
|
|
17474
17587
|
await STORE.save(flush=False)
|
|
17475
17588
|
|
|
17476
17589
|
|
|
17477
|
-
|
|
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:
|
|
17478
17606
|
"""Wait briefly for the one startup queue scan, then fail retryably.
|
|
17479
17607
|
|
|
17480
17608
|
The shared recovery task is shielded because disconnecting one request
|
|
@@ -17483,42 +17611,34 @@ async def wait_for_queue_recovery_admission() -> None:
|
|
|
17483
17611
|
ordering hole with only a partial in-memory queue.
|
|
17484
17612
|
"""
|
|
17485
17613
|
|
|
17614
|
+
if session_id is not None and session_id in QUEUE_RECOVERY_READY:
|
|
17615
|
+
return
|
|
17486
17616
|
recovery = QUEUE_RECOVERY_TASK
|
|
17487
17617
|
if recovery is None or recovery is asyncio.current_task():
|
|
17488
17618
|
return
|
|
17489
|
-
|
|
17490
|
-
|
|
17491
|
-
|
|
17492
|
-
|
|
17493
|
-
|
|
17494
|
-
|
|
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:
|
|
17495
17625
|
raise TransientAdmissionWait(
|
|
17496
17626
|
status_code=503,
|
|
17497
|
-
detail=
|
|
17498
|
-
|
|
17499
|
-
|
|
17500
|
-
|
|
17501
|
-
) from exc
|
|
17627
|
+
detail="queued turn recovery is still in progress; retry this request shortly",
|
|
17628
|
+
)
|
|
17629
|
+
try:
|
|
17630
|
+
recovery.result()
|
|
17502
17631
|
except asyncio.CancelledError:
|
|
17503
|
-
|
|
17504
|
-
|
|
17505
|
-
|
|
17506
|
-
|
|
17507
|
-
|
|
17508
|
-
|
|
17509
|
-
detail=(
|
|
17510
|
-
"queued turn recovery did not complete; restart or retry "
|
|
17511
|
-
"the managed server"
|
|
17512
|
-
),
|
|
17513
|
-
) from None
|
|
17514
|
-
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
|
|
17515
17638
|
except Exception as exc:
|
|
17516
17639
|
raise TransientAdmissionWait(
|
|
17517
17640
|
status_code=503,
|
|
17518
|
-
detail=
|
|
17519
|
-
"queued turn recovery did not complete; restart or retry the "
|
|
17520
|
-
"managed server"
|
|
17521
|
-
),
|
|
17641
|
+
detail="queued turn recovery did not complete; restart the managed server; if it repeats, inspect server logs",
|
|
17522
17642
|
) from exc
|
|
17523
17643
|
|
|
17524
17644
|
|
|
@@ -17531,7 +17651,7 @@ async def enqueue_turn(
|
|
|
17531
17651
|
secure_peer_route_snapshots: list[dict[str, Any]] | None = None,
|
|
17532
17652
|
reciprocal_route_grant: dict[str, Any] | None = None,
|
|
17533
17653
|
) -> dict[str, Any]:
|
|
17534
|
-
await wait_for_queue_recovery_admission()
|
|
17654
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
17535
17655
|
if not routed_references_match_visible_prompt(
|
|
17536
17656
|
req.prompt,
|
|
17537
17657
|
req.display_prompt,
|
|
@@ -17803,6 +17923,7 @@ async def enqueue_turn(
|
|
|
17803
17923
|
),
|
|
17804
17924
|
"prompt": display_prompt,
|
|
17805
17925
|
"request_prompt": req.prompt,
|
|
17926
|
+
"client_request_id": getattr(req, "client_request_id", None),
|
|
17806
17927
|
"display_prompt": req.display_prompt,
|
|
17807
17928
|
"file_ids": list(req.file_ids),
|
|
17808
17929
|
"position": position,
|
|
@@ -18068,7 +18189,7 @@ async def enqueue_turn(
|
|
|
18068
18189
|
|
|
18069
18190
|
|
|
18070
18191
|
async def unqueue_turn(session_id: str, queued_id: str) -> dict[str, Any]:
|
|
18071
|
-
await wait_for_queue_recovery_admission()
|
|
18192
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
18072
18193
|
if session_id not in STORE.sessions:
|
|
18073
18194
|
raise HTTPException(status_code=404, detail="session not found")
|
|
18074
18195
|
|
|
@@ -20732,7 +20853,7 @@ def provider_tool_active_matches(
|
|
|
20732
20853
|
expected_transport = (
|
|
20733
20854
|
CODEX_TRANSPORT_APP_SERVER
|
|
20734
20855
|
if backend == BACKEND_CODEX
|
|
20735
|
-
else "exec" if backend in {BACKEND_CURSOR, BACKEND_OPENCODE}
|
|
20856
|
+
else "exec" if backend in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}
|
|
20736
20857
|
else CLAUDE_TRANSPORT_AGENT_SDK
|
|
20737
20858
|
)
|
|
20738
20859
|
matches = bool(
|
|
@@ -20757,7 +20878,7 @@ def provider_tool_active_matches(
|
|
|
20757
20878
|
and str(active.get("provider_turn_id") or "") == provider_turn_id
|
|
20758
20879
|
and active.get("provider_turn_ready") is True
|
|
20759
20880
|
)
|
|
20760
|
-
elif backend in {BACKEND_CURSOR, BACKEND_OPENCODE}:
|
|
20881
|
+
elif backend in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}:
|
|
20761
20882
|
proc = active.get("proc")
|
|
20762
20883
|
matches = bool(
|
|
20763
20884
|
matches and cursor_owner_token
|
|
@@ -20830,7 +20951,7 @@ async def provider_tool_capability_snapshot(
|
|
|
20830
20951
|
and codex_native_mailbox_owner_matches(session_id, run_id, provider_thread_id, provider_turn_id))
|
|
20831
20952
|
):
|
|
20832
20953
|
raise ProviderToolError("provider tool turn is stale")
|
|
20833
|
-
elif backend not in {BACKEND_CURSOR, BACKEND_OPENCODE}:
|
|
20954
|
+
elif backend not in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}:
|
|
20834
20955
|
raise ProviderToolError("provider tool backend is unsupported")
|
|
20835
20956
|
|
|
20836
20957
|
async with ACTIVE_LOCK:
|
|
@@ -22494,7 +22615,7 @@ def public_queued_turn(
|
|
|
22494
22615
|
|
|
22495
22616
|
|
|
22496
22617
|
async def queued_turns_snapshot(session_id: str) -> list[dict[str, Any]]:
|
|
22497
|
-
await wait_for_queue_recovery_admission()
|
|
22618
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
22498
22619
|
async with QUEUE_LOCK:
|
|
22499
22620
|
queue = list(QUEUED_TURNS.get(session_id) or [])
|
|
22500
22621
|
run_now = RUN_NOW_TURNS.get(session_id)
|
|
@@ -22554,7 +22675,7 @@ def reject_promoted_queue_mutation(session_id: str, queued_id: str) -> None:
|
|
|
22554
22675
|
|
|
22555
22676
|
|
|
22556
22677
|
async def update_queued_turn(session_id: str, queued_id: str, req: UpdateQueuedTurnRequest) -> dict[str, Any]:
|
|
22557
|
-
await wait_for_queue_recovery_admission()
|
|
22678
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
22558
22679
|
if session_id not in STORE.sessions:
|
|
22559
22680
|
raise HTTPException(status_code=404, detail="session not found")
|
|
22560
22681
|
async_result = await update_async_queued_message(session_id, queued_id, req)
|
|
@@ -23001,7 +23122,7 @@ async def update_queued_turn(session_id: str, queued_id: str, req: UpdateQueuedT
|
|
|
23001
23122
|
|
|
23002
23123
|
|
|
23003
23124
|
async def move_queued_turn(session_id: str, queued_id: str, req: MoveQueuedTurnRequest) -> dict[str, Any]:
|
|
23004
|
-
await wait_for_queue_recovery_admission()
|
|
23125
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
23005
23126
|
if session_id not in STORE.sessions:
|
|
23006
23127
|
raise HTTPException(status_code=404, detail="session not found")
|
|
23007
23128
|
direction = req.direction.strip().lower()
|
|
@@ -23516,7 +23637,7 @@ async def reconcile_idle_queued_turns(*, reason: str) -> int:
|
|
|
23516
23637
|
|
|
23517
23638
|
async def run_queued_turn_now(session_id: str, queued_id: str) -> dict[str, Any]:
|
|
23518
23639
|
"""Coalesce duplicate Force Send requests without replaying a steer."""
|
|
23519
|
-
await wait_for_queue_recovery_admission()
|
|
23640
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
23520
23641
|
ensure_session_not_deleting(session_id)
|
|
23521
23642
|
await reconcile_idle_queue_session(
|
|
23522
23643
|
session_id,
|
|
@@ -25161,7 +25282,7 @@ def schedule_steered_turn_slot_waiter(
|
|
|
25161
25282
|
|
|
25162
25283
|
|
|
25163
25284
|
async def start_next_queued_turn(session_id: str) -> None:
|
|
25164
|
-
await wait_for_queue_recovery_admission()
|
|
25285
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
25165
25286
|
await reconcile_idle_queue_session(
|
|
25166
25287
|
session_id,
|
|
25167
25288
|
schedule=False,
|
|
@@ -25803,14 +25924,78 @@ def queued_turn_from_event(event: dict[str, Any], sess: dict[str, Any], position
|
|
|
25803
25924
|
}
|
|
25804
25925
|
|
|
25805
25926
|
|
|
25806
|
-
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]:
|
|
25807
25929
|
with path.open("rb") as source:
|
|
25808
|
-
|
|
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()
|
|
25809
25943
|
if (b'"queued_id"' in raw_line or b'"turn_queue_paused"' in raw_line
|
|
25810
25944
|
or (include_envelopes and b'"cross_chat_envelope_id"' in raw_line)):
|
|
25811
25945
|
yield raw_line
|
|
25812
25946
|
|
|
25813
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
|
+
|
|
25814
25999
|
def record_mailbox_migration_evidence(evidence: dict[str, dict[str, Any]], event: dict[str, Any]) -> None:
|
|
25815
26000
|
item = evidence.get(str(event.get("cross_chat_envelope_id") or ""))
|
|
25816
26001
|
if item is None:
|
|
@@ -25844,11 +26029,13 @@ def scan_queued_turns_from_events(
|
|
|
25844
26029
|
value["complete"] = False
|
|
25845
26030
|
if not path.exists():
|
|
25846
26031
|
continue
|
|
25847
|
-
|
|
26032
|
+
scan_stamp = path.stat()
|
|
26033
|
+
proof_stamp = scan_stamp if evidence else None
|
|
25848
26034
|
proof_complete = True
|
|
25849
|
-
|
|
25850
|
-
|
|
25851
|
-
|
|
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):
|
|
25852
26039
|
try:
|
|
25853
26040
|
event = json.loads(raw_line.decode("utf-8", "replace"))
|
|
25854
26041
|
except Exception:
|
|
@@ -26008,7 +26195,9 @@ def scan_queued_turns_from_events(
|
|
|
26008
26195
|
unchanged = ((proof_stamp.st_dev, proof_stamp.st_ino, proof_stamp.st_size, proof_stamp.st_mtime_ns)
|
|
26009
26196
|
== (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns))
|
|
26010
26197
|
for value in evidence.values():
|
|
26011
|
-
|
|
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"]
|
|
26012
26201
|
items = [
|
|
26013
26202
|
pending[qid]
|
|
26014
26203
|
for qid in order
|
|
@@ -26019,6 +26208,9 @@ def scan_queued_turns_from_events(
|
|
|
26019
26208
|
]
|
|
26020
26209
|
if items:
|
|
26021
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])
|
|
26022
26214
|
return recovered
|
|
26023
26215
|
|
|
26024
26216
|
|
|
@@ -26182,13 +26374,48 @@ async def migrate_unstarted_chat_mailbox_backlog(
|
|
|
26182
26374
|
return migrated
|
|
26183
26375
|
|
|
26184
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
|
+
|
|
26185
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]:
|
|
26186
26418
|
started = time.monotonic()
|
|
26187
|
-
session_items = [
|
|
26188
|
-
(session_id, dict(sess))
|
|
26189
|
-
for session_id, sess in STORE.sessions.items()
|
|
26190
|
-
]
|
|
26191
|
-
migration_candidates = await CROSS_CHAT.pending_mailbox_migration_candidates()
|
|
26192
26419
|
mailbox_evidence = {str(record["id"]): {"target_session_id": record["target_session_id"]}
|
|
26193
26420
|
for record in migration_candidates if is_async_route_message(record)}
|
|
26194
26421
|
recovered = await asyncio.to_thread(scan_queued_turns_from_events, session_items,
|
|
@@ -27577,6 +27804,40 @@ def linux_process_still_exists(
|
|
|
27577
27804
|
return True
|
|
27578
27805
|
|
|
27579
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
|
+
|
|
27580
27841
|
def managed_update_service_cgroup_state(
|
|
27581
27842
|
*,
|
|
27582
27843
|
service_cgroup: str | None = None,
|
|
@@ -27648,10 +27909,11 @@ def managed_update_service_cgroup_state(
|
|
|
27648
27909
|
})
|
|
27649
27910
|
return state
|
|
27650
27911
|
unknown = tuple(sorted(service_pids - {os.getpid()}))
|
|
27912
|
+
preserved = bool(unknown) and systemd_worker_preserves_descendants(selected_cgroup)
|
|
27651
27913
|
state.update({
|
|
27652
|
-
"safe": not unknown,
|
|
27914
|
+
"safe": not unknown or preserved,
|
|
27653
27915
|
"unknown_descendant_count": len(unknown),
|
|
27654
|
-
"inspection": "verified",
|
|
27916
|
+
"inspection": "verified-preserved" if preserved else "verified",
|
|
27655
27917
|
"_unknown_descendant_pids": unknown,
|
|
27656
27918
|
})
|
|
27657
27919
|
return state
|
|
@@ -29963,6 +30225,14 @@ async def reconcile_provider_task_exit(
|
|
|
29963
30225
|
prevents delayed cleanup from touching a replacement turn.
|
|
29964
30226
|
"""
|
|
29965
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
|
|
29966
30236
|
persisted_active = (STORE.sessions.get(session_id) or {}).get("active_run")
|
|
29967
30237
|
terminal_missing = bool(
|
|
29968
30238
|
isinstance(persisted_active, dict)
|
|
@@ -32094,6 +32364,9 @@ SEMANTIC_TIMELINE_EVENT_BUDGET_PER_ITEM = 4
|
|
|
32094
32364
|
SEMANTIC_TIMELINE_ESSENTIAL_LIMIT_PER_ITEM = 32
|
|
32095
32365
|
SEMANTIC_TIMELINE_ESSENTIAL_PAGE_OVERFLOW_LIMIT = 128
|
|
32096
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
|
|
32097
32370
|
JOB_RUN_HISTORY_TEXT_LIMIT = 64 * 1024
|
|
32098
32371
|
SEMANTIC_TIMELINE_ESSENTIAL_DETAIL_TYPES = {
|
|
32099
32372
|
"artifact_created",
|
|
@@ -32373,14 +32646,14 @@ CODEX_GOAL_HISTORY_REPAIR_CACHE = CodexGoalHistoryRepairCache()
|
|
|
32373
32646
|
CODEX_NATIVE_HISTORY_REPAIR_CACHE = CodexNativeHistoryRepairCache()
|
|
32374
32647
|
|
|
32375
32648
|
|
|
32376
|
-
def prepare_provider_history_metadata_repair(session_id: str) -> None:
|
|
32377
|
-
"""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."""
|
|
32378
32651
|
prepare_claude_history_metadata_repair(session_id)
|
|
32379
32652
|
prepare_codex_goal_history_repair(session_id)
|
|
32380
|
-
prepare_codex_native_history_repair(session_id)
|
|
32653
|
+
return prepare_codex_native_history_repair(session_id)
|
|
32381
32654
|
|
|
32382
32655
|
|
|
32383
|
-
def prepare_codex_native_history_repair(session_id: str) -> None:
|
|
32656
|
+
def prepare_codex_native_history_repair(session_id: str) -> bool | None:
|
|
32384
32657
|
session = STORE.sessions.get(session_id)
|
|
32385
32658
|
if not isinstance(session, dict) or session.get("backend") != BACKEND_CODEX or session.get("_deleting") is True:
|
|
32386
32659
|
CODEX_NATIVE_HISTORY_REPAIR_CACHE.forget(session_id)
|
|
@@ -32408,11 +32681,13 @@ def prepare_codex_native_history_repair(session_id: str) -> None:
|
|
|
32408
32681
|
session_id, provider_id, events_path(session_id), source, CODEX_SESSIONS_ROOT,
|
|
32409
32682
|
lambda event: codex_history_event_item(event, expected_session_id=session_id),
|
|
32410
32683
|
)
|
|
32411
|
-
except CodexNativeHistoryProofUnavailable:
|
|
32684
|
+
except CodexNativeHistoryProofUnavailable as exc:
|
|
32685
|
+
if getattr(exc, "deterministic", False):
|
|
32686
|
+
raise
|
|
32412
32687
|
# Incomplete evidence is retryable at a later explicit read boundary,
|
|
32413
32688
|
# not a successful empty proof and not a reason to break chat opening.
|
|
32414
32689
|
logger.debug("native history repair deferred session=%s", session_id)
|
|
32415
|
-
return
|
|
32690
|
+
return False
|
|
32416
32691
|
if changed:
|
|
32417
32692
|
HISTORY_SEARCH_REPAIR_DIRTY.add(session_id)
|
|
32418
32693
|
HISTORY_SEARCH_DIRTY.add(session_id)
|
|
@@ -32975,7 +33250,6 @@ def build_timeline_index(session_id: str) -> dict[str, Any]:
|
|
|
32975
33250
|
|
|
32976
33251
|
|
|
32977
33252
|
def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
32978
|
-
prepare_provider_history_metadata_repair(session_id)
|
|
32979
33253
|
claude_metadata_signature = CLAUDE_METADATA_REPAIR_CACHE.signature(session_id)
|
|
32980
33254
|
codex_goal_history_signature = (
|
|
32981
33255
|
CODEX_GOAL_HISTORY_REPAIR_CACHE.signature(session_id),
|
|
@@ -33063,6 +33337,15 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
|
33063
33337
|
if can_append
|
|
33064
33338
|
else []
|
|
33065
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)
|
|
33066
33349
|
dirty_record_keys: set[str] = set()
|
|
33067
33350
|
active_turn_key: str | None = cached.get("active_turn_key") if can_append else None
|
|
33068
33351
|
current_turn_by_run: dict[str, str] = (
|
|
@@ -33383,6 +33666,8 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
|
33383
33666
|
}
|
|
33384
33667
|
by_key[key] = record
|
|
33385
33668
|
records.append(record)
|
|
33669
|
+
if key.startswith("turn:"):
|
|
33670
|
+
turn_record_keys_by_run.setdefault(key[5:].split(":start-", 1)[0], []).append(key)
|
|
33386
33671
|
if advance_timeline:
|
|
33387
33672
|
latest_timeline_landmark_key = key
|
|
33388
33673
|
record["start_seq"] = min(int(record["start_seq"]), seq)
|
|
@@ -33849,16 +34134,10 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
|
33849
34134
|
job_by_run[run_id] = job_id
|
|
33850
34135
|
prior_key = current_turn_by_run.pop(run_id, None) if run_id else None
|
|
33851
34136
|
if not prior_key and run_id:
|
|
33852
|
-
|
|
33853
|
-
|
|
33854
|
-
(
|
|
33855
|
-
|
|
33856
|
-
for candidate in reversed(records)
|
|
33857
|
-
if str(candidate.get("key") or "") == base_turn_key
|
|
33858
|
-
or str(candidate.get("key") or "").startswith(f"{base_turn_key}:start-")
|
|
33859
|
-
),
|
|
33860
|
-
None,
|
|
33861
|
-
)
|
|
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
|
|
33862
34141
|
prior = by_key.get(prior_key or "")
|
|
33863
34142
|
group_key, group_created = assign_job_timeline_group(
|
|
33864
34143
|
event,
|
|
@@ -35484,9 +35763,6 @@ def collect_semantic_timeline_events(
|
|
|
35484
35763
|
deduplicated[identity] = event
|
|
35485
35764
|
return True
|
|
35486
35765
|
|
|
35487
|
-
for event in page_repairs:
|
|
35488
|
-
append_with_limit(event, event_limit)
|
|
35489
|
-
|
|
35490
35766
|
# Every selected semantic item gets one representative before any item
|
|
35491
35767
|
# receives detail. The page-size clamp in read_semantic_timeline_page()
|
|
35492
35768
|
# guarantees that this baseline always fits.
|
|
@@ -35501,6 +35777,22 @@ def collect_semantic_timeline_events(
|
|
|
35501
35777
|
for event in secondary:
|
|
35502
35778
|
append_with_limit(event, event_limit)
|
|
35503
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
|
+
|
|
35504
35796
|
# Files and reviewable diffs are semantic output, not optional trace
|
|
35505
35797
|
# detail. Give each selected item a generous but bounded overflow and also
|
|
35506
35798
|
# cap total page overflow. This preserves normal multi-video workflows
|
|
@@ -38383,7 +38675,7 @@ def cross_chat_supported_target_backends() -> list[str]:
|
|
|
38383
38675
|
# Only cached diagnostics/private metadata are read here; health must not
|
|
38384
38676
|
# launch CLIs or authenticate remotely. Each target's actual turn admission
|
|
38385
38677
|
# still checks its own binding, CLI compatibility and selected login mode.
|
|
38386
|
-
for backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
|
|
38678
|
+
for backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
|
|
38387
38679
|
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
38388
38680
|
diagnostic = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
|
|
38389
38681
|
ready = diagnostic.get("status") == "ready"
|
|
@@ -38509,15 +38801,21 @@ def cross_chat_handoffs_capability() -> dict[str, Any]:
|
|
|
38509
38801
|
BACKEND_CLAUDE: CLAUDE_TRANSPORT_AGENT_SDK,
|
|
38510
38802
|
BACKEND_CURSOR: "headless-stream-json",
|
|
38511
38803
|
BACKEND_OPENCODE: "headless-stream-json",
|
|
38804
|
+
BACKEND_GEMINI: "acp",
|
|
38512
38805
|
},
|
|
38513
38806
|
}
|
|
38514
38807
|
|
|
38515
38808
|
|
|
38516
38809
|
OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY = "opencode_cross_chat_v1"
|
|
38810
|
+
GEMINI_CLIENT_CAPABILITY = "gemini_acp_v1"
|
|
38517
38811
|
|
|
38518
38812
|
|
|
38519
38813
|
def cross_chat_delivery_client_capabilities(target: dict[str, Any]) -> list[str]:
|
|
38520
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]
|
|
38521
38819
|
if backend == BACKEND_CODEX:
|
|
38522
38820
|
if not cross_chat_target_backend_supported(backend):
|
|
38523
38821
|
raise HTTPException(
|
|
@@ -38560,6 +38858,7 @@ CROSS_CHAT_DELIVERY_CAPABILITIES_BY_BACKEND: dict[str, frozenset[str]] = {
|
|
|
38560
38858
|
BACKEND_CLAUDE: frozenset({CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY}),
|
|
38561
38859
|
BACKEND_CURSOR: frozenset(),
|
|
38562
38860
|
BACKEND_OPENCODE: frozenset({OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY}),
|
|
38861
|
+
BACKEND_GEMINI: frozenset({GEMINI_CLIENT_CAPABILITY}),
|
|
38563
38862
|
}
|
|
38564
38863
|
|
|
38565
38864
|
|
|
@@ -43293,7 +43592,9 @@ def find_emergency_request_event(
|
|
|
43293
43592
|
request_id: str,
|
|
43294
43593
|
source_run_id: str,
|
|
43295
43594
|
) -> dict[str, Any] | None:
|
|
43296
|
-
for event in reversed_jsonl_events(
|
|
43595
|
+
for event in reversed_jsonl_events(
|
|
43596
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43597
|
+
):
|
|
43297
43598
|
if (
|
|
43298
43599
|
event.get("type") == "emergency_alert_raised"
|
|
43299
43600
|
and event.get("emergency_request_id") == request_id
|
|
@@ -43308,7 +43609,9 @@ def emergency_alert_was_acknowledged(
|
|
|
43308
43609
|
alert_id: str,
|
|
43309
43610
|
raised_seq: int,
|
|
43310
43611
|
) -> bool:
|
|
43311
|
-
for event in reversed_jsonl_events(
|
|
43612
|
+
for event in reversed_jsonl_events(
|
|
43613
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43614
|
+
):
|
|
43312
43615
|
seq = int(event.get("seq") or 0)
|
|
43313
43616
|
if seq <= raised_seq:
|
|
43314
43617
|
return False
|
|
@@ -43324,7 +43627,9 @@ def find_emergency_acknowledgement_event(
|
|
|
43324
43627
|
session_id: str,
|
|
43325
43628
|
alert_id: str,
|
|
43326
43629
|
) -> dict[str, Any] | None:
|
|
43327
|
-
for event in reversed_jsonl_events(
|
|
43630
|
+
for event in reversed_jsonl_events(
|
|
43631
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43632
|
+
):
|
|
43328
43633
|
if (
|
|
43329
43634
|
event.get("type") == "emergency_alert_acknowledged"
|
|
43330
43635
|
and event.get("emergency_alert_id") == alert_id
|
|
@@ -43333,6 +43638,41 @@ def find_emergency_acknowledgement_event(
|
|
|
43333
43638
|
return None
|
|
43334
43639
|
|
|
43335
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
|
+
|
|
43336
43676
|
def emergency_alert_response(
|
|
43337
43677
|
session_id: str,
|
|
43338
43678
|
event: dict[str, Any],
|
|
@@ -44945,7 +45285,7 @@ def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Itera
|
|
|
44945
45285
|
|
|
44946
45286
|
def bounded_jsonl_records_range(
|
|
44947
45287
|
path: Path, start: int, end: int, *, expected_stat: dict[str, int],
|
|
44948
|
-
require_newline: bool = True,
|
|
45288
|
+
require_newline: bool = True, allow_partial: bool = False,
|
|
44949
45289
|
) -> Iterator[tuple[dict[str, Any] | None, int]]:
|
|
44950
45290
|
"""Read every record and ending offset in one immutable JSONL range."""
|
|
44951
45291
|
if start < 0 or end < start or end > expected_stat["st_size"]:
|
|
@@ -44955,10 +45295,12 @@ def bounded_jsonl_records_range(
|
|
|
44955
45295
|
if any(int(getattr(before, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
|
|
44956
45296
|
raise ValueError("transcript changed before cursor parsing")
|
|
44957
45297
|
stream.seek(start)
|
|
44958
|
-
for event, offset, _raw in jsonl_records(stream, end=end,
|
|
45298
|
+
for count, (event, offset, _raw) in enumerate(jsonl_records(stream, end=end,
|
|
44959
45299
|
require_newline=require_newline, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
|
|
44960
|
-
max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES):
|
|
45300
|
+
max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES), 1):
|
|
44961
45301
|
yield event, offset
|
|
45302
|
+
if allow_partial and count >= MAX_LOCAL_TRANSCRIPT_SCAN_LINES:
|
|
45303
|
+
break
|
|
44962
45304
|
after = os.fstat(stream.fileno())
|
|
44963
45305
|
if any(int(getattr(after, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
|
|
44964
45306
|
raise ValueError("transcript changed during cursor parsing")
|
|
@@ -45252,20 +45594,7 @@ def find_codex_history(provider_id: str) -> Path | None:
|
|
|
45252
45594
|
clean_provider_id = provider_session_identifier(provider_id)
|
|
45253
45595
|
if clean_provider_id is None:
|
|
45254
45596
|
return None
|
|
45255
|
-
|
|
45256
|
-
return None
|
|
45257
|
-
selected: Path | None = None
|
|
45258
|
-
selected_mtime = -1.0
|
|
45259
|
-
for path in bounded_jsonl_paths(CODEX_SESSIONS_ROOT):
|
|
45260
|
-
transcript_id, _cwd = codex_transcript_meta(path)
|
|
45261
|
-
if transcript_id != clean_provider_id:
|
|
45262
|
-
continue
|
|
45263
|
-
with suppress(OSError):
|
|
45264
|
-
mtime = path.stat().st_mtime
|
|
45265
|
-
if mtime > selected_mtime:
|
|
45266
|
-
selected = path
|
|
45267
|
-
selected_mtime = mtime
|
|
45268
|
-
return selected
|
|
45597
|
+
return codex_rollout_path(clean_provider_id)
|
|
45269
45598
|
|
|
45270
45599
|
|
|
45271
45600
|
def codex_compaction_count(sess: dict[str, Any]) -> int:
|
|
@@ -45307,7 +45636,7 @@ def codex_rollout_path(thread_id: str) -> Path | None:
|
|
|
45307
45636
|
"""Locate ``rollout-*-<thread_id>.jsonl`` under CODEX_SESSIONS_ROOT.
|
|
45308
45637
|
|
|
45309
45638
|
Blocking filesystem work; call from a worker thread. Hits are cached per
|
|
45310
|
-
thread id and re-validated with
|
|
45639
|
+
thread id and re-validated with a bounded metadata read. Misses are cached briefly so a
|
|
45311
45640
|
thread whose rollout has not been flushed yet does not trigger a directory
|
|
45312
45641
|
walk on every runtime snapshot.
|
|
45313
45642
|
"""
|
|
@@ -45317,7 +45646,7 @@ def codex_rollout_path(thread_id: str) -> Path | None:
|
|
|
45317
45646
|
return None
|
|
45318
45647
|
cached = CODEX_ROLLOUT_PATH_CACHE.get(clean_id)
|
|
45319
45648
|
if cached is not None:
|
|
45320
|
-
if cached
|
|
45649
|
+
if contained_jsonl_path(cached, CODEX_SESSIONS_ROOT) is not None and codex_transcript_meta(cached)[0] == clean_id:
|
|
45321
45650
|
return cached
|
|
45322
45651
|
CODEX_ROLLOUT_PATH_CACHE.pop(clean_id, None)
|
|
45323
45652
|
missed_at = CODEX_ROLLOUT_PATH_MISSES.get(clean_id)
|
|
@@ -45330,17 +45659,29 @@ def codex_rollout_path(thread_id: str) -> Path | None:
|
|
|
45330
45659
|
suffix = f"-{clean_id}.jsonl"
|
|
45331
45660
|
selected: Path | None = None
|
|
45332
45661
|
selected_mtime = -1.0
|
|
45662
|
+
legacy_paths: list[Path] = []
|
|
45333
45663
|
for path in bounded_jsonl_paths(CODEX_SESSIONS_ROOT):
|
|
45334
45664
|
name = path.name
|
|
45335
45665
|
if not (name.startswith("rollout-") and name.endswith(suffix)):
|
|
45666
|
+
legacy_paths.append(path)
|
|
45336
45667
|
continue
|
|
45337
|
-
if
|
|
45668
|
+
if codex_transcript_meta(path)[0] != clean_id:
|
|
45338
45669
|
continue
|
|
45339
45670
|
with suppress(OSError):
|
|
45340
45671
|
mtime = path.stat().st_mtime
|
|
45341
45672
|
if mtime > selected_mtime:
|
|
45342
45673
|
selected = path
|
|
45343
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
|
|
45344
45685
|
if selected is None:
|
|
45345
45686
|
CODEX_ROLLOUT_PATH_MISSES[clean_id] = now
|
|
45346
45687
|
if len(CODEX_ROLLOUT_PATH_MISSES) > 1024:
|
|
@@ -46531,6 +46872,8 @@ def session_provider_id(sess: dict[str, Any]) -> str | None:
|
|
|
46531
46872
|
return sess.get("cursor_session_id") or sess.get("session_id")
|
|
46532
46873
|
if backend == BACKEND_OPENCODE:
|
|
46533
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")
|
|
46534
46877
|
return sess.get("session_id")
|
|
46535
46878
|
|
|
46536
46879
|
|
|
@@ -46670,6 +47013,12 @@ def read_native_session_title(sess: dict[str, Any]) -> str | None:
|
|
|
46670
47013
|
return native_session_title(codex_session_index_thread_names().get(provider_id))
|
|
46671
47014
|
if backend == BACKEND_CURSOR:
|
|
46672
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"))
|
|
46673
47022
|
if backend == "opencode":
|
|
46674
47023
|
# Current native storage. A schema change, missing store, or busy DB
|
|
46675
47024
|
# fails closed. mode=ro must never create or migrate a provider DB.
|
|
@@ -46754,7 +47103,8 @@ def generated_title_eligible(sess: dict[str, Any], *, allow_attempted: bool = Fa
|
|
|
46754
47103
|
return bool(
|
|
46755
47104
|
os.environ.get("AGENTSDOCK_AUTO_TITLES", "1").strip().lower() not in {"0", "false", "off"}
|
|
46756
47105
|
and not SERVER_SHUTTING_DOWN
|
|
46757
|
-
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"))
|
|
46758
47108
|
and sess.get("auto_title_enabled", True) is not False
|
|
46759
47109
|
and not sess.get("archived") and not sess.get("parent_id") and not sess.get("fork_from")
|
|
46760
47110
|
and sess.get("id") not in DELETING_SESSIONS
|
|
@@ -46781,6 +47131,9 @@ async def close_generated_session_titles() -> None:
|
|
|
46781
47131
|
|
|
46782
47132
|
async def generate_session_title(session_id: str, snapshot: dict, reply: str) -> None:
|
|
46783
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)
|
|
46784
47137
|
# Persist the claim before spending any provider usage. A restart does
|
|
46785
47138
|
# not retry an ambiguous request. This is one optional attempt per chat.
|
|
46786
47139
|
async with STORE._lock:
|
|
@@ -46807,6 +47160,27 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
|
|
|
46807
47160
|
options["model"] = codex_runtime_settings(snapshot)[0]
|
|
46808
47161
|
options["executable"] = CODEX_BIN
|
|
46809
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}
|
|
46810
47184
|
else:
|
|
46811
47185
|
options["executable"] = resolve_cursor_executable()
|
|
46812
47186
|
if not options["executable"]:
|
|
@@ -46818,6 +47192,9 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
|
|
|
46818
47192
|
snapshot["backend"], snapshot["_title_seed"], reply, **options,
|
|
46819
47193
|
)
|
|
46820
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.
|
|
46821
47198
|
changed = await STORE.adopt_auto_title(
|
|
46822
47199
|
session_id, result, source="generated", backend=snapshot["backend"],
|
|
46823
47200
|
provider_id=session_provider_id(snapshot), expected_title=snapshot.get("title"),
|
|
@@ -46968,7 +47345,7 @@ def codex_transcript_meta(
|
|
|
46968
47345
|
) -> tuple[str | None, str | None]:
|
|
46969
47346
|
"""Return (session_id, cwd) from a Codex transcript's session_meta record."""
|
|
46970
47347
|
with suppress(OSError, ValueError):
|
|
46971
|
-
for index, event in enumerate(
|
|
47348
|
+
for index, event in enumerate(codex_transcript_metadata_records(path)):
|
|
46972
47349
|
if index >= CODEX_TRANSCRIPT_SCAN_LINES:
|
|
46973
47350
|
break
|
|
46974
47351
|
if event.get("type") != "session_meta":
|
|
@@ -46984,6 +47361,23 @@ def codex_transcript_meta(
|
|
|
46984
47361
|
return None, None
|
|
46985
47362
|
|
|
46986
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
|
+
|
|
46987
47381
|
def codex_transcript_preview(path: Path) -> str | None:
|
|
46988
47382
|
"""Return a short snippet of the first user message, for a local-session label."""
|
|
46989
47383
|
with suppress(OSError, ValueError):
|
|
@@ -47216,6 +47610,7 @@ def local_session_candidates(
|
|
|
47216
47610
|
known_provider_keys: set[tuple[str, str]] | None = None,
|
|
47217
47611
|
*,
|
|
47218
47612
|
include_cursor: bool = False,
|
|
47613
|
+
include_gemini: bool = False,
|
|
47219
47614
|
) -> list[dict[str, Any]]:
|
|
47220
47615
|
"""Enumerate sessions not already used by this or another local instance."""
|
|
47221
47616
|
|
|
@@ -47244,6 +47639,11 @@ def local_session_candidates(
|
|
|
47244
47639
|
candidates.extend(local_cursor_session_candidates({
|
|
47245
47640
|
provider_id for backend, provider_id in known_provider_keys if backend == BACKEND_CURSOR
|
|
47246
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
|
+
}))
|
|
47247
47647
|
candidates.sort(key=lambda c: c["updated_at"], reverse=True)
|
|
47248
47648
|
return candidates[:max(1, min(limit, MAX_LOCAL_SESSION_LIST_ITEMS))]
|
|
47249
47649
|
|
|
@@ -47291,6 +47691,8 @@ def standalone_provider_session(sess: dict[str, Any]) -> dict[str, Any]:
|
|
|
47291
47691
|
isolated["codex_thread_id"] = None
|
|
47292
47692
|
isolated["cursor_session_id"] = None
|
|
47293
47693
|
isolated["opencode_session_id"] = None
|
|
47694
|
+
isolated["gemini_session_id"] = None
|
|
47695
|
+
isolated.pop("gemini_session_cwd", None)
|
|
47294
47696
|
isolated["backend_locked"] = False
|
|
47295
47697
|
isolated.pop("cursor_instruction_hash", None)
|
|
47296
47698
|
isolated.pop("cursor_instruction_version", None)
|
|
@@ -47330,6 +47732,12 @@ def provider_history_path(sess: dict[str, Any]) -> Path | None:
|
|
|
47330
47732
|
if backend == BACKEND_CLAUDE:
|
|
47331
47733
|
return find_claude_history(provider_id)
|
|
47332
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
|
|
47333
47741
|
return find_codex_history(provider_id)
|
|
47334
47742
|
return None
|
|
47335
47743
|
|
|
@@ -47452,6 +47860,8 @@ def normalized_history_sync_cursor(
|
|
|
47452
47860
|
raw.get("timeline_pending_through_seq") or 0
|
|
47453
47861
|
),
|
|
47454
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 {}),
|
|
47455
47865
|
"checkpoint_seq": int(raw.get("checkpoint_seq", raw["timeline_seq"])),
|
|
47456
47866
|
}
|
|
47457
47867
|
# Distinguish a pre-feature cursor from an initialized conservative empty
|
|
@@ -47633,7 +48043,12 @@ def provider_history_source_snapshot(
|
|
|
47633
48043
|
int(opened.st_mtime_ns),
|
|
47634
48044
|
) != expected_identity:
|
|
47635
48045
|
raise ValueError("transcript changed before cursor snapshot")
|
|
47636
|
-
|
|
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
|
|
47637
48052
|
while remaining > 0:
|
|
47638
48053
|
chunk = stream.read(min(1024 * 1024, remaining))
|
|
47639
48054
|
if not chunk:
|
|
@@ -47648,10 +48063,15 @@ def provider_history_source_snapshot(
|
|
|
47648
48063
|
if newline >= 0:
|
|
47649
48064
|
running_digest.update(chunk[:newline + 1])
|
|
47650
48065
|
complete_digest = running_digest.copy()
|
|
47651
|
-
complete_size =
|
|
48066
|
+
complete_size = consumed + newline + 1
|
|
47652
48067
|
running_digest.update(chunk[newline + 1:])
|
|
47653
48068
|
else:
|
|
47654
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)
|
|
47655
48075
|
finished = os.fstat(stream.fileno())
|
|
47656
48076
|
if (
|
|
47657
48077
|
int(finished.st_dev),
|
|
@@ -47804,6 +48224,7 @@ def parse_provider_history_delta(
|
|
|
47804
48224
|
start,
|
|
47805
48225
|
end,
|
|
47806
48226
|
expected_stat=expected_stat,
|
|
48227
|
+
allow_partial=True,
|
|
47807
48228
|
):
|
|
47808
48229
|
if blocked_on_unseen_message:
|
|
47809
48230
|
# Exhaust the fixed range so its final fstat validation still
|
|
@@ -47934,51 +48355,27 @@ def load_provider_history_with_cursor(
|
|
|
47934
48355
|
if "claude_interruption_context" in previous
|
|
47935
48356
|
else seed_claude_interruption_context(path, start, expected_stat=snapshot["expected_stat"], provider_session_id=provider_id)
|
|
47936
48357
|
)
|
|
47937
|
-
|
|
47938
|
-
|
|
47939
|
-
|
|
47940
|
-
|
|
47941
|
-
|
|
47942
|
-
|
|
47943
|
-
|
|
47944
|
-
|
|
47945
|
-
|
|
47946
|
-
|
|
47947
|
-
|
|
47948
|
-
|
|
47949
|
-
|
|
47950
|
-
|
|
47951
|
-
|
|
47952
|
-
|
|
47953
|
-
|
|
47954
|
-
|
|
47955
|
-
|
|
47956
|
-
|
|
47957
|
-
|
|
47958
|
-
caught_up = not delta_overflow and cursor_offset == end
|
|
47959
|
-
else:
|
|
47960
|
-
events = bounded_jsonl_events_range(
|
|
47961
|
-
path,
|
|
47962
|
-
0,
|
|
47963
|
-
end,
|
|
47964
|
-
expected_stat=snapshot["expected_stat"],
|
|
47965
|
-
preserve_invalid=backend == BACKEND_CLAUDE,
|
|
47966
|
-
)
|
|
47967
|
-
if backend == BACKEND_CLAUDE:
|
|
47968
|
-
items = parse_claude_history_events(events, limit, expected_session_id=str(sess.get("id") or "") or None, interruption_context=interruption_context)
|
|
47969
|
-
elif backend == BACKEND_CODEX:
|
|
47970
|
-
items = parse_codex_history_events(events, limit, expected_session_id=str(sess.get("id") or "") or None)
|
|
47971
|
-
else:
|
|
47972
|
-
return None, [], None, False
|
|
47973
|
-
cursor_offset = end
|
|
47974
|
-
last_item_digest = (
|
|
47975
|
-
history_item_cursor_digest(items[-1]) if items else ""
|
|
47976
|
-
)
|
|
47977
|
-
if items and items[-1].get("source_text_sha256"):
|
|
47978
|
-
source_text_context["sha256"] = items[-1]["source_text_sha256"]
|
|
47979
|
-
if backend == BACKEND_CODEX and items and items[-1].get("phase") in ("commentary", "final_answer"):
|
|
47980
|
-
codex_phase_context["phase"] = items[-1]["phase"]
|
|
47981
|
-
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"])
|
|
47982
48379
|
source_digest = (
|
|
47983
48380
|
str(snapshot["source_digest"])
|
|
47984
48381
|
if cursor_offset == end
|
|
@@ -48341,6 +48738,7 @@ def reconcile_cursor_history_items(
|
|
|
48341
48738
|
*,
|
|
48342
48739
|
timeline_after_seq: int,
|
|
48343
48740
|
timeline_through_seq: int,
|
|
48741
|
+
bootstrap: bool = False,
|
|
48344
48742
|
) -> tuple[list[dict[str, str]], int]:
|
|
48345
48743
|
"""Subtract timeline-owned occurrences from one exact provider delta.
|
|
48346
48744
|
|
|
@@ -48364,14 +48762,15 @@ def reconcile_cursor_history_items(
|
|
|
48364
48762
|
timeline_after_seq=after_seq,
|
|
48365
48763
|
timeline_through_seq=through_seq,
|
|
48366
48764
|
tail=False,
|
|
48367
|
-
include_imported=
|
|
48765
|
+
include_imported=bootstrap,
|
|
48368
48766
|
message_details=message_details,
|
|
48369
48767
|
)
|
|
48370
48768
|
|
|
48371
48769
|
fresh: list[dict[str, str]] = []
|
|
48372
48770
|
timeline_index = 0
|
|
48373
48771
|
consumed_seq = after_seq
|
|
48374
|
-
|
|
48772
|
+
last_match_index = -1
|
|
48773
|
+
for item_index, item in enumerate(items):
|
|
48375
48774
|
item_details = history_message_match_details(item.get("kind", ""), item.get("text", ""), item, provider_item=True)
|
|
48376
48775
|
if (
|
|
48377
48776
|
timeline_index < len(timeline_messages)
|
|
@@ -48381,6 +48780,7 @@ def reconcile_cursor_history_items(
|
|
|
48381
48780
|
):
|
|
48382
48781
|
consumed_seq = timeline_messages[timeline_index][0]
|
|
48383
48782
|
timeline_index += 1
|
|
48783
|
+
last_match_index = item_index
|
|
48384
48784
|
else:
|
|
48385
48785
|
fresh.append(item)
|
|
48386
48786
|
|
|
@@ -48395,6 +48795,11 @@ def reconcile_cursor_history_items(
|
|
|
48395
48795
|
if timeline_window_truncated and timeline_messages
|
|
48396
48796
|
else through_seq
|
|
48397
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 []
|
|
48398
48803
|
return fresh, consumed_seq
|
|
48399
48804
|
|
|
48400
48805
|
|
|
@@ -48516,10 +48921,48 @@ def unsynced_history_items(
|
|
|
48516
48921
|
return items[last_matched + 1:]
|
|
48517
48922
|
|
|
48518
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
|
+
|
|
48519
48961
|
async def sync_provider_history(
|
|
48520
48962
|
sess: dict[str, Any],
|
|
48521
48963
|
*,
|
|
48522
48964
|
limit: int | None = None,
|
|
48965
|
+
background: bool = False,
|
|
48523
48966
|
) -> dict[str, Any]:
|
|
48524
48967
|
"""Append provider messages added outside this chat since the last sync.
|
|
48525
48968
|
|
|
@@ -48540,6 +48983,7 @@ async def sync_provider_history(
|
|
|
48540
48983
|
}
|
|
48541
48984
|
live_session = STORE.sessions.get(session_id) or {}
|
|
48542
48985
|
previous_cursor = normalized_history_sync_cursor(live_session)
|
|
48986
|
+
timeline_stamp = history_file_stamp(events_path(session_id))
|
|
48543
48987
|
# A durable cursor makes the unchanged fast path survive process restart;
|
|
48544
48988
|
# all five stat/identity fields must still describe the fully-consumed
|
|
48545
48989
|
# source before parsing can be skipped.
|
|
@@ -48562,6 +49006,10 @@ async def sync_provider_history(
|
|
|
48562
49006
|
"source_path": None,
|
|
48563
49007
|
"message": "No provider transcript found.",
|
|
48564
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"}
|
|
48565
49013
|
if next_cursor is not None and previous_cursor is not None:
|
|
48566
49014
|
recovered = await asyncio.to_thread(
|
|
48567
49015
|
committed_history_sync_checkpoint,
|
|
@@ -48570,11 +49018,11 @@ async def sync_provider_history(
|
|
|
48570
49018
|
)
|
|
48571
49019
|
if recovered is not None:
|
|
48572
49020
|
recovered_cursor, _terminal_seq = recovered
|
|
48573
|
-
|
|
48574
|
-
|
|
48575
|
-
|
|
48576
|
-
|
|
48577
|
-
|
|
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
|
+
)
|
|
48578
49026
|
return {
|
|
48579
49027
|
"imported": 0,
|
|
48580
49028
|
"source_path": str(source_path),
|
|
@@ -48584,14 +49032,18 @@ async def sync_provider_history(
|
|
|
48584
49032
|
last_event_seq_from_file,
|
|
48585
49033
|
events_path(session_id),
|
|
48586
49034
|
)
|
|
48587
|
-
|
|
48588
|
-
|
|
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
|
|
48589
49040
|
fresh, consumed_timeline_seq = await asyncio.to_thread(
|
|
48590
49041
|
reconcile_cursor_history_items,
|
|
48591
49042
|
session_id,
|
|
48592
49043
|
items,
|
|
48593
|
-
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,
|
|
48594
49045
|
timeline_through_seq=timeline_scan_through_seq,
|
|
49046
|
+
**({"bootstrap": True} if bootstrap_boundary is not None else {}),
|
|
48595
49047
|
)
|
|
48596
49048
|
else:
|
|
48597
49049
|
# First sync or invalidated/rotated source: retain the conservative
|
|
@@ -48612,11 +49064,11 @@ async def sync_provider_history(
|
|
|
48612
49064
|
)
|
|
48613
49065
|
if recovered is not None:
|
|
48614
49066
|
recovered_cursor, _terminal_seq = recovered
|
|
48615
|
-
|
|
48616
|
-
|
|
48617
|
-
|
|
48618
|
-
|
|
48619
|
-
|
|
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
|
+
)
|
|
48620
49072
|
return {
|
|
48621
49073
|
"imported": 0,
|
|
48622
49074
|
"source_path": str(source_path),
|
|
@@ -48627,6 +49079,8 @@ async def sync_provider_history(
|
|
|
48627
49079
|
timeline_seq = timeline_latest_seq
|
|
48628
49080
|
if next_cursor is not None:
|
|
48629
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
|
|
48630
49084
|
next_cursor["timeline_pending_through_seq"] = 0
|
|
48631
49085
|
next_cursor["timeline_pending_active"] = False
|
|
48632
49086
|
if not caught_up:
|
|
@@ -48642,50 +49096,59 @@ async def sync_provider_history(
|
|
|
48642
49096
|
caught_up=caught_up,
|
|
48643
49097
|
)
|
|
48644
49098
|
|
|
48645
|
-
|
|
48646
|
-
if not fresh:
|
|
48647
|
-
result = {
|
|
48648
|
-
"imported": 0,
|
|
48649
|
-
"source_path": str(source_path),
|
|
48650
|
-
"message": "Already up to date with the provider transcript.",
|
|
48651
|
-
}
|
|
48652
|
-
else:
|
|
48653
|
-
# The imported batch is fsynced atomically together with its exact next
|
|
48654
|
-
# cursor. A cursor moves only after that authoritative commit; if the
|
|
48655
|
-
# registry save then fails, retry recovers the embedded checkpoint
|
|
48656
|
-
# without relying on ambiguous content alignment.
|
|
49099
|
+
if background and fresh:
|
|
48657
49100
|
try:
|
|
48658
|
-
|
|
48659
|
-
sess,
|
|
48660
|
-
source_path,
|
|
48661
|
-
fresh,
|
|
48662
|
-
sync_checkpoint=checkpoint,
|
|
48663
|
-
)
|
|
49101
|
+
fresh = await prepare_imported_history_items(sess, source_path, fresh, checkpoint)
|
|
48664
49102
|
except CodexNativeHistoryProofUnavailable:
|
|
48665
|
-
|
|
48666
|
-
|
|
48667
|
-
|
|
48668
|
-
|
|
48669
|
-
|
|
48670
|
-
"
|
|
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.",
|
|
48671
49111
|
}
|
|
48672
|
-
|
|
48673
|
-
|
|
48674
|
-
|
|
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),
|
|
48675
49142
|
timeline_latest_seq,
|
|
48676
49143
|
int(result.get("timeline_seq") or 0),
|
|
48677
49144
|
)
|
|
48678
|
-
|
|
48679
|
-
|
|
48680
|
-
|
|
48681
|
-
|
|
48682
|
-
|
|
48683
|
-
|
|
48684
|
-
|
|
48685
|
-
next_cursor,
|
|
48686
|
-
timeline_seq=timeline_seq,
|
|
48687
|
-
)
|
|
48688
|
-
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
|
|
48689
49152
|
|
|
48690
49153
|
|
|
48691
49154
|
def provider_history_source_stamp(sess: dict[str, Any]) -> list[Any] | None:
|
|
@@ -48767,73 +49230,115 @@ async def persist_history_sync_cursor(
|
|
|
48767
49230
|
return True
|
|
48768
49231
|
|
|
48769
49232
|
|
|
48770
|
-
|
|
48771
|
-
|
|
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
|
+
|
|
48772
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]
|
|
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
|
|
48773
49278
|
try:
|
|
48774
|
-
|
|
48775
|
-
# controls use this same lifecycle lock. Revalidate after acquiring it
|
|
48776
|
-
# so a sync scheduled while idle cannot import a transcript that a new
|
|
48777
|
-
# live turn is concurrently extending.
|
|
48778
|
-
async with session_lifecycle_lock(session_id):
|
|
49279
|
+
async with HISTORY_MAINTENANCE_SLOTS:
|
|
48779
49280
|
sess = STORE.sessions.get(session_id)
|
|
48780
|
-
if (
|
|
48781
|
-
|
|
48782
|
-
|
|
48783
|
-
|
|
48784
|
-
):
|
|
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):
|
|
48785
49285
|
return
|
|
48786
|
-
|
|
48787
|
-
|
|
48788
|
-
session_id in BUSY_SESSIONS
|
|
48789
|
-
or ACTIVE.get(session_id) is not None
|
|
48790
|
-
or session_id in SERVER_MAINTENANCE_SESSIONS
|
|
48791
|
-
or any(
|
|
48792
|
-
task is not asyncio.current_task() and not task.done()
|
|
48793
|
-
for task in tuple(SESSION_TURN_TASKS.get(session_id) or ())
|
|
48794
|
-
)
|
|
48795
|
-
):
|
|
48796
|
-
return
|
|
49286
|
+
key = history_maintenance_key(sess)
|
|
49287
|
+
state.update(status="running", reason=None)
|
|
48797
49288
|
await refresh_native_session_title(session_id)
|
|
48798
|
-
|
|
48799
|
-
|
|
48800
|
-
|
|
48801
|
-
|
|
48802
|
-
|
|
48803
|
-
|
|
48804
|
-
|
|
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)
|
|
48805
49305
|
except asyncio.CancelledError:
|
|
49306
|
+
state.update(status="deferred", reason="cancelled")
|
|
48806
49307
|
raise
|
|
48807
49308
|
except Exception as exc:
|
|
48808
|
-
|
|
48809
|
-
logger.warning(
|
|
48810
|
-
"provider history sync failed session=%s error=%s",
|
|
48811
|
-
session_id,
|
|
48812
|
-
type(exc).__name__,
|
|
48813
|
-
)
|
|
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__)
|
|
48814
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))
|
|
48815
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)
|
|
48816
49328
|
|
|
48817
49329
|
|
|
48818
49330
|
def schedule_provider_history_sync(sess: dict[str, Any]) -> None:
|
|
48819
|
-
"""Catch one chat's transcript up when opened, without blocking the open.
|
|
48820
|
-
|
|
48821
|
-
Opening a chat must stay fast, and the client is already subscribed to
|
|
48822
|
-
the event stream, so recovered messages arrive live instead of delaying
|
|
48823
|
-
the response.
|
|
48824
|
-
"""
|
|
48825
|
-
|
|
48826
49331
|
session_id = str(sess.get("id") or "")
|
|
48827
|
-
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):
|
|
48828
49333
|
return
|
|
48829
|
-
if not session_provider_id(sess):
|
|
48830
|
-
return
|
|
48831
|
-
# A turn in flight is already streaming the provider's own output and its
|
|
48832
|
-
# transcript writes are still landing.
|
|
48833
49334
|
if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
|
|
48834
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
|
|
48835
49340
|
HISTORY_SYNC_SCHEDULED.add(session_id)
|
|
48836
|
-
asyncio.create_task(run_provider_history_sync(session_id))
|
|
49341
|
+
HISTORY_MAINTENANCE_TASKS[session_id] = asyncio.create_task(run_provider_history_sync(session_id))
|
|
48837
49342
|
|
|
48838
49343
|
|
|
48839
49344
|
async def filter_codex_history_for_import(
|
|
@@ -48861,13 +49366,10 @@ async def filter_codex_history_for_import(
|
|
|
48861
49366
|
raise
|
|
48862
49367
|
|
|
48863
49368
|
|
|
48864
|
-
async def
|
|
48865
|
-
sess: dict[str, Any],
|
|
48866
|
-
|
|
48867
|
-
|
|
48868
|
-
*,
|
|
48869
|
-
sync_checkpoint: dict[str, Any] | None = None,
|
|
48870
|
-
) -> 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]]:
|
|
48871
49373
|
session_id = str(sess["id"])
|
|
48872
49374
|
provider_id = str(session_provider_id(sess) or "")
|
|
48873
49375
|
backend = str(sess.get("backend") or DEFAULT_BACKEND).lower()
|
|
@@ -48894,6 +49396,23 @@ async def append_imported_history(
|
|
|
48894
49396
|
normalize_user=normalize_wake_user, normalize_full_user=normalize_full_wake_user,
|
|
48895
49397
|
normalize_assistant=clean_assistant_text,
|
|
48896
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)
|
|
48897
49416
|
metadata_only = bool(items) and all(item.get("kind") == "interruption" or (
|
|
48898
49417
|
item.get("provider_history_repair") in {"source_proven_import", "source_proven_native_replay", "source_proven_assistant_replay"}
|
|
48899
49418
|
and item.get("text") == ""
|
|
@@ -48983,7 +49502,13 @@ async def append_imported_history(
|
|
|
48983
49502
|
imported_events.append(imported_history_terminal_event(
|
|
48984
49503
|
run_id, backend, metadata_only=metadata_only, source_timestamp=last_source_timestamp,
|
|
48985
49504
|
))
|
|
48986
|
-
|
|
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)
|
|
48987
49512
|
if len(committed) != len(imported_events):
|
|
48988
49513
|
raise RuntimeError("history event batch was not fully persisted")
|
|
48989
49514
|
if backend == BACKEND_CODEX:
|
|
@@ -49121,6 +49646,12 @@ async def import_session_history(sess: dict[str, Any], *, force: bool = False, l
|
|
|
49121
49646
|
backend = (sess.get("backend") or DEFAULT_BACKEND).lower()
|
|
49122
49647
|
if not provider_id:
|
|
49123
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)
|
|
49124
49655
|
if backend == BACKEND_CURSOR:
|
|
49125
49656
|
# Initial public-text snapshot only. The private native store, not these
|
|
49126
49657
|
# display messages, supplies resume context. Cursor exports are rewritable
|
|
@@ -50158,9 +50689,10 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
50158
50689
|
detail_fields = () if summary else (
|
|
50159
50690
|
"auto_title_enabled",
|
|
50160
50691
|
"system_prompt", "session_id", "claude_session_id", "codex_thread_id",
|
|
50161
|
-
"cursor_session_id", "opencode_session_id",
|
|
50692
|
+
"cursor_session_id", "opencode_session_id", "gemini_session_id",
|
|
50162
50693
|
"claude_permission_mode", "cursor_permission_mode",
|
|
50163
50694
|
"opencode_permission_mode",
|
|
50695
|
+
"gemini_permission_mode", "gemini_permission_modes",
|
|
50164
50696
|
"codex_approval_policy", "codex_sandbox_mode",
|
|
50165
50697
|
"codex_permission_profile", "codex_approvals_reviewer",
|
|
50166
50698
|
"provider_jobs_access",
|
|
@@ -50181,6 +50713,11 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
50181
50713
|
"last_read_agent_event_seq", "last_read_agent_event_at", "manual_unread",
|
|
50182
50714
|
)
|
|
50183
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")}
|
|
50184
50721
|
# Omit only never-configured defaults from high-volume lists. A stored null
|
|
50185
50722
|
# is a clear tombstone and must survive summary/cache merges.
|
|
50186
50723
|
if not summary or "subagent_limit" in sess:
|
|
@@ -50208,6 +50745,10 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
50208
50745
|
if sess.get("title") == "New chat" and not public["backend_locked"]:
|
|
50209
50746
|
public["empty_chat_cleanup"] = "client-v1"
|
|
50210
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
|
|
50211
50752
|
connection = sess.get("provider_connection") or "default"
|
|
50212
50753
|
# Keep legacy native summaries sparse, but retain a stored default as the
|
|
50213
50754
|
# tombstone that clears a previously selected custom API in client caches.
|
|
@@ -57165,6 +57706,9 @@ def resolve_cursor_executable(*, require_compatible: bool = True) -> str | None:
|
|
|
57165
57706
|
|
|
57166
57707
|
|
|
57167
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"
|
|
57168
57712
|
if backend == BACKEND_CLAUDE:
|
|
57169
57713
|
return CLAUDE_BIN
|
|
57170
57714
|
if backend == BACKEND_OPENCODE:
|
|
@@ -57183,6 +57727,8 @@ def runtime_executable(backend: str) -> str:
|
|
|
57183
57727
|
|
|
57184
57728
|
|
|
57185
57729
|
def runtime_display_name(backend: str) -> str:
|
|
57730
|
+
if backend == BACKEND_GEMINI:
|
|
57731
|
+
return "Gemini CLI"
|
|
57186
57732
|
if backend == BACKEND_CLAUDE:
|
|
57187
57733
|
return "Claude Code"
|
|
57188
57734
|
if backend == BACKEND_CURSOR:
|
|
@@ -57412,6 +57958,9 @@ def cursor_auth_failure_confirmed(executable: str) -> bool:
|
|
|
57412
57958
|
|
|
57413
57959
|
|
|
57414
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))
|
|
57415
57964
|
if backend == BACKEND_OPENCODE:
|
|
57416
57965
|
(
|
|
57417
57966
|
resolved,
|
|
@@ -57635,7 +58184,14 @@ def probe_runtime(backend: str) -> dict[str, Any]:
|
|
|
57635
58184
|
|
|
57636
58185
|
combined = f"{auth_result.stdout}\n{auth_result.stderr}"
|
|
57637
58186
|
if auth_result.returncode == 0:
|
|
57638
|
-
|
|
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
|
|
57639
58195
|
if auth_failure_text(combined):
|
|
57640
58196
|
return runtime_diagnostic_payload(backend, "unauthenticated", installed=True, authenticated=False, version=version)
|
|
57641
58197
|
return runtime_diagnostic_payload(backend, "error", installed=True, authenticated=None, version=version)
|
|
@@ -57658,10 +58214,14 @@ def store_runtime_diagnostic(diagnostic: dict[str, Any], *, preserve_last_error:
|
|
|
57658
58214
|
|
|
57659
58215
|
def expire_codex_login_diagnostic() -> None:
|
|
57660
58216
|
"""A login hint requires new native evidence, never reuse a cached denial."""
|
|
58217
|
+
global CODEX_MODEL_MIGRATION_TARGET
|
|
57661
58218
|
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
58219
|
+
CODEX_MODEL_MIGRATION_TARGET = "gpt-6-luna"
|
|
57662
58220
|
current = dict(RUNTIME_DIAGNOSTICS.get(BACKEND_CODEX) or runtime_diagnostic_payload(
|
|
57663
58221
|
BACKEND_CODEX, "unknown", installed=None, authenticated=None))
|
|
57664
58222
|
current["checked_at_epoch"] = 0.0
|
|
58223
|
+
current.pop("_codex_auth_mode", None)
|
|
58224
|
+
current.pop("_codex_auth_mode_checked_at_epoch", None)
|
|
57665
58225
|
store_runtime_diagnostic(current)
|
|
57666
58226
|
|
|
57667
58227
|
|
|
@@ -57670,6 +58230,8 @@ def runtime_diagnostic(backend: str, *, force: bool = False) -> dict[str, Any]:
|
|
|
57670
58230
|
cached = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
|
|
57671
58231
|
generation = RUNTIME_DIAGNOSTIC_GENERATIONS.get(backend, 0)
|
|
57672
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"])
|
|
57673
58235
|
if not force and isinstance(checked_at, (int, float)) and time.time() - checked_at < RUNTIME_DIAGNOSTIC_TTL_SECONDS:
|
|
57674
58236
|
return cached
|
|
57675
58237
|
try:
|
|
@@ -57814,14 +58376,34 @@ def record_runtime_success(backend: str) -> None:
|
|
|
57814
58376
|
version=previous.get("version"),
|
|
57815
58377
|
executable=(
|
|
57816
58378
|
str(previous.get("_executable") or "") or None
|
|
57817
|
-
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE)
|
|
58379
|
+
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI)
|
|
57818
58380
|
else None
|
|
57819
58381
|
),
|
|
57820
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
|
+
)
|
|
57821
58390
|
store_runtime_diagnostic(current, preserve_last_error=False)
|
|
57822
58391
|
|
|
57823
58392
|
|
|
57824
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
|
|
57825
58407
|
diagnostic = await asyncio.to_thread(runtime_diagnostic, backend)
|
|
57826
58408
|
if (session or {}).get("provider_connection") == "custom":
|
|
57827
58409
|
selected = PROVIDER_CONNECTION_STORE.for_session(session)
|
|
@@ -57986,6 +58568,26 @@ def codex_default_service_tier(model: str) -> str:
|
|
|
57986
58568
|
return CODEX_FALLBACK_SERVICE_TIERS.get(str(model or "").strip(), "")
|
|
57987
58569
|
|
|
57988
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
|
+
|
|
57989
58591
|
# The Codex app-server's thread/start and turn-override JSON-RPC params only
|
|
57990
58592
|
# accept a narrower serviceTier enum ("fast"/"flex") than the canonical
|
|
57991
58593
|
# service_tier values used for `codex exec` CLI config overrides (see
|
|
@@ -58002,6 +58604,7 @@ def codex_app_server_service_tier(service_tier: str) -> str:
|
|
|
58002
58604
|
|
|
58003
58605
|
|
|
58004
58606
|
def discover_codex_catalog() -> dict[str, Any]:
|
|
58607
|
+
global CODEX_MODEL_MIGRATION_TARGET
|
|
58005
58608
|
models: list[dict[str, Any]] = []
|
|
58006
58609
|
model_options: list[dict[str, Any]] = []
|
|
58007
58610
|
effort_options: list[dict[str, Any]] = []
|
|
@@ -58024,16 +58627,29 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58024
58627
|
model_source = f"{model_source} failed"
|
|
58025
58628
|
effort_source = f"{effort_source} failed"
|
|
58026
58629
|
|
|
58630
|
+
replacements = codex_retired_model_replacements()
|
|
58027
58631
|
visible_models = [
|
|
58028
58632
|
model for model in models
|
|
58029
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
|
|
58030
58635
|
]
|
|
58031
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}
|
|
58032
58648
|
# The selectable "Server default" must describe the exact settings that
|
|
58033
58649
|
# codex_runtime_settings() launches for a chat with no explicit override.
|
|
58034
58650
|
# The CLI's discovered list can omit a configured/custom default (or a
|
|
58035
58651
|
# legacy fallback), so the first discovered model is not a safe proxy.
|
|
58036
|
-
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)
|
|
58037
58653
|
default_effort = clamp_codex_runtime_effort(
|
|
58038
58654
|
default_model,
|
|
58039
58655
|
configured_effort or CODEX_DEFAULT_EFFORT,
|
|
@@ -58080,6 +58696,8 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58080
58696
|
|
|
58081
58697
|
if not model_options:
|
|
58082
58698
|
for slug, label in CODEX_FALLBACK_MODELS:
|
|
58699
|
+
if slug in replacements:
|
|
58700
|
+
continue
|
|
58083
58701
|
fallback_efforts = [
|
|
58084
58702
|
runtime_option(effort, title_effort_label(effort))
|
|
58085
58703
|
for effort in CODEX_FALLBACK_MODEL_EFFORTS.get(slug, CODEX_FALLBACK_EFFORTS)
|
|
@@ -58089,7 +58707,7 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58089
58707
|
model_options.append(runtime_option(slug, label, efforts=fallback_efforts, service_tier=service_tier or None))
|
|
58090
58708
|
if not effort_options:
|
|
58091
58709
|
effort_options.extend(runtime_option(effort, title_effort_label(effort)) for effort in CODEX_FALLBACK_EFFORTS)
|
|
58092
|
-
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):
|
|
58093
58711
|
model_options.insert(0, runtime_option(configured_model, title_model_label(configured_model)))
|
|
58094
58712
|
if configured_effort and not any(option.get("value") == configured_effort for option in effort_options):
|
|
58095
58713
|
effort_options.append(runtime_option(configured_effort, title_effort_label(configured_effort)))
|
|
@@ -58112,6 +58730,7 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58112
58730
|
"default_model": default_model or None,
|
|
58113
58731
|
"default_effort": default_effort or None,
|
|
58114
58732
|
"default_service_tier": default_service_tier or None,
|
|
58733
|
+
"model_replacements": replacements,
|
|
58115
58734
|
}
|
|
58116
58735
|
|
|
58117
58736
|
|
|
@@ -58607,6 +59226,13 @@ def discover_runtime_backend_catalog(backend: str, *, force_runtime_probe: bool
|
|
|
58607
59226
|
diagnostic = runtime_diagnostic(backend, force=force_runtime_probe)
|
|
58608
59227
|
ready = diagnostic.get("status") == "ready"
|
|
58609
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)}
|
|
58610
59236
|
if backend == BACKEND_CLAUDE:
|
|
58611
59237
|
catalog = parse_claude_help_catalog()
|
|
58612
59238
|
elif backend == BACKEND_CODEX:
|
|
@@ -58699,7 +59325,7 @@ def codex_runtime_settings(sess: dict[str, Any]) -> tuple[str, str, str]:
|
|
|
58699
59325
|
catalog = CODEX_PROVIDER_STORE.cached_catalog(selected)
|
|
58700
59326
|
return model, codex_provider.runtime_effort(selected, catalog, sess.get("effort")), ""
|
|
58701
59327
|
configured_model, configured_effort, configured_service_tier = codex_user_config_defaults()
|
|
58702
|
-
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())
|
|
58703
59329
|
effort = clamp_codex_runtime_effort(
|
|
58704
59330
|
model,
|
|
58705
59331
|
sess.get("effort") or configured_effort or CODEX_DEFAULT_EFFORT,
|
|
@@ -58708,6 +59334,39 @@ def codex_runtime_settings(sess: dict[str, Any]) -> tuple[str, str, str]:
|
|
|
58708
59334
|
return model, effort, service_tier
|
|
58709
59335
|
|
|
58710
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
|
+
|
|
58711
59370
|
def codex_goal_time_budget_remaining(sess: dict[str, Any]) -> float | None:
|
|
58712
59371
|
goal = sess.get("codex_goal")
|
|
58713
59372
|
limit = sess.get("codex_goal_time_budget_seconds")
|
|
@@ -59975,7 +60634,7 @@ async def discover_session_provider_commands(
|
|
|
59975
60634
|
|
|
59976
60635
|
backend = str(session.get("backend") or DEFAULT_BACKEND).strip().lower()
|
|
59977
60636
|
requested_cwd = str(session.get("cwd") or DEFAULT_CWD)
|
|
59978
|
-
if backend
|
|
60637
|
+
if backend in {BACKEND_OPENCODE, BACKEND_GEMINI}:
|
|
59979
60638
|
try:
|
|
59980
60639
|
cwd = validated_opencode_cwd(requested_cwd)
|
|
59981
60640
|
except HTTPException:
|
|
@@ -59989,7 +60648,7 @@ async def discover_session_provider_commands(
|
|
|
59989
60648
|
support = provider_command_support(
|
|
59990
60649
|
False,
|
|
59991
60650
|
"unavailable",
|
|
59992
|
-
"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.",
|
|
59993
60652
|
)
|
|
59994
60653
|
return provider_command_snapshot(fallback_empty, support), fallback_empty
|
|
59995
60654
|
else:
|
|
@@ -60029,7 +60688,7 @@ async def discover_session_provider_commands(
|
|
|
60029
60688
|
provider_command_snapshot(fallback_empty, support),
|
|
60030
60689
|
fallback_empty,
|
|
60031
60690
|
)
|
|
60032
|
-
elif backend
|
|
60691
|
+
elif backend in {BACKEND_OPENCODE, BACKEND_GEMINI}:
|
|
60033
60692
|
pass
|
|
60034
60693
|
else:
|
|
60035
60694
|
support = provider_command_support(
|
|
@@ -60123,6 +60782,26 @@ async def discover_session_provider_commands(
|
|
|
60123
60782
|
f"Showing the first {MAX_PROVIDER_COMMANDS} provider commands."
|
|
60124
60783
|
)
|
|
60125
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
|
|
60126
60805
|
if backend == BACKEND_OPENCODE:
|
|
60127
60806
|
try:
|
|
60128
60807
|
env = runner_env()
|
|
@@ -60200,6 +60879,8 @@ async def resolve_provider_command_selection(
|
|
|
60200
60879
|
if backend == BACKEND_CLAUDE
|
|
60201
60880
|
else OPENCODE_PROVIDER_COMMANDS_CLIENT_CAPABILITY
|
|
60202
60881
|
if backend == BACKEND_OPENCODE
|
|
60882
|
+
else GEMINI_CLIENT_CAPABILITY
|
|
60883
|
+
if backend == BACKEND_GEMINI
|
|
60203
60884
|
else None
|
|
60204
60885
|
)
|
|
60205
60886
|
if required_capability is None:
|
|
@@ -60637,7 +61318,9 @@ async def update_reasoning_summary_stream(
|
|
|
60637
61318
|
phase: str = "summary",
|
|
60638
61319
|
) -> None:
|
|
60639
61320
|
"""Keep provider-exposed summary and plaintext reasoning channels distinct."""
|
|
60640
|
-
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":
|
|
60641
61324
|
return
|
|
60642
61325
|
index = params.get("contentIndex" if phase == "reasoning" else "summaryIndex", 0)
|
|
60643
61326
|
delta = params.get("delta", "")
|
|
@@ -60657,7 +61340,7 @@ async def update_reasoning_summary_stream(
|
|
|
60657
61340
|
after_seq = await asyncio.to_thread(last_event_seq_from_file, events_path(session_id))
|
|
60658
61341
|
state = {"parts": {}, "item": {
|
|
60659
61342
|
**dict(metadata or {}), "run_id": run_id, "item_id": item_id,
|
|
60660
|
-
"backend": BACKEND_CODEX, "phase": phase, "text": "",
|
|
61343
|
+
"backend": (metadata or {}).get("backend", BACKEND_CODEX), "phase": phase, "text": "",
|
|
60661
61344
|
"ts": now_iso(), "after_seq": after_seq,
|
|
60662
61345
|
}}
|
|
60663
61346
|
streams[(run_id, item_id, phase)] = state
|
|
@@ -68856,6 +69539,35 @@ async def retain_codex_goal_run_owner(
|
|
|
68856
69539
|
return True
|
|
68857
69540
|
|
|
68858
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
|
+
|
|
68859
69571
|
async def run_codex_app_server(
|
|
68860
69572
|
session_id: str,
|
|
68861
69573
|
run_id: str,
|
|
@@ -71279,6 +71991,7 @@ async def run_codex_app_server(
|
|
|
71279
71991
|
thread_pinned = False
|
|
71280
71992
|
can_fallback = (
|
|
71281
71993
|
allow_exec_fallback
|
|
71994
|
+
and not codex_native_archive_rejection(exc, str(session_provider_id(sess) or ""))
|
|
71282
71995
|
and codex_provider.session_choice(sess.get("codex_provider")) == "default"
|
|
71283
71996
|
and provider_command is None
|
|
71284
71997
|
and not stop_requested
|
|
@@ -71343,6 +72056,15 @@ async def run_codex_app_server(
|
|
|
71343
72056
|
**current_metadata(),
|
|
71344
72057
|
})
|
|
71345
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)
|
|
71346
72068
|
finally:
|
|
71347
72069
|
async def settle_transport_finalizer() -> None:
|
|
71348
72070
|
"""Retire every owner before cancellation can escape the runner."""
|
|
@@ -71738,7 +72460,7 @@ async def start_turn(
|
|
|
71738
72460
|
scheduled_job_revision: str | None = None,
|
|
71739
72461
|
scheduled_job_manual_run: bool = False,
|
|
71740
72462
|
) -> dict[str, Any]:
|
|
71741
|
-
await wait_for_queue_recovery_admission()
|
|
72463
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
71742
72464
|
if queue_if_busy and provider_context_mode == "chat":
|
|
71743
72465
|
await reconcile_idle_queue_session(
|
|
71744
72466
|
session_id,
|
|
@@ -71811,7 +72533,7 @@ async def _start_turn_locked(
|
|
|
71811
72533
|
# Internal delivery paths can enter with the lifecycle lock already held
|
|
71812
72534
|
# and intentionally bypass ``start_turn``. They still must not start or
|
|
71813
72535
|
# queue ahead of an undiscovered durable startup row.
|
|
71814
|
-
await wait_for_queue_recovery_admission()
|
|
72536
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
71815
72537
|
if (
|
|
71816
72538
|
req.purpose == SECURE_PEER_DELIVERY_PURPOSE
|
|
71817
72539
|
and not SECURE_PEER_AGENT_RELAY_ENABLED
|
|
@@ -71833,6 +72555,8 @@ async def _start_turn_locked(
|
|
|
71833
72555
|
raise HTTPException(status_code=404, detail="session not found")
|
|
71834
72556
|
if sess.get("archived"):
|
|
71835
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.")
|
|
71836
72560
|
if req.purpose == "chat_mailbox_wake" or mailbox_wake_claim is not None:
|
|
71837
72561
|
if (req.purpose != "chat_mailbox_wake" or mailbox_wake_claim is None
|
|
71838
72562
|
or mailbox_wake_claim.get("target_session_id") != session_id
|
|
@@ -72122,6 +72846,9 @@ async def _start_turn_locked(
|
|
|
72122
72846
|
),
|
|
72123
72847
|
runtime_validation_patch,
|
|
72124
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.")
|
|
72125
72852
|
if str(runtime_preview_session.get("backend") or DEFAULT_BACKEND).strip().lower() == BACKEND_CODEX:
|
|
72126
72853
|
await prepare_codex_login_turn(runtime_preview_session)
|
|
72127
72854
|
opencode_cwd: str | None = None
|
|
@@ -72168,6 +72895,10 @@ async def _start_turn_locked(
|
|
|
72168
72895
|
session_id,
|
|
72169
72896
|
list(item.get("file_ids") or []),
|
|
72170
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))
|
|
72171
72902
|
# OpenCode's native --file path has no provider-side size ceiling and can
|
|
72172
72903
|
# eagerly ingest local data. Resolve and bound the exact current message
|
|
72173
72904
|
# before either reserving a slot or durably accepting it into the queue.
|
|
@@ -72384,6 +73115,9 @@ async def _start_turn_locked(
|
|
|
72384
73115
|
if team_mail_grant_mutation:
|
|
72385
73116
|
team_mail_route_snapshot = team_mail_grants.admission_snapshot(team_mail_grant_mutation, sess)
|
|
72386
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
|
|
72387
73121
|
runtime_patch: dict[str, Any] = {}
|
|
72388
73122
|
if req.backend:
|
|
72389
73123
|
runtime_patch["backend"] = req.backend
|
|
@@ -72412,14 +73146,20 @@ async def _start_turn_locked(
|
|
|
72412
73146
|
|
|
72413
73147
|
backend = sess.get("backend") or DEFAULT_BACKEND
|
|
72414
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
|
+
)
|
|
72415
73154
|
if provider_context_mode == "chat":
|
|
72416
73155
|
sess = await STORE.mark_backend_started(session_id, str(backend))
|
|
72417
|
-
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
|
|
73156
|
+
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
|
|
72418
73157
|
# Keep the exact compatibility-probed executable pinned through
|
|
72419
73158
|
# admission; mark_backend_started returns a fresh durable snapshot.
|
|
72420
73159
|
sess = dict(sess)
|
|
72421
73160
|
executable_key = (
|
|
72422
|
-
"
|
|
73161
|
+
"_gemini_executable" if backend == BACKEND_GEMINI
|
|
73162
|
+
else "_opencode_executable" if backend == BACKEND_OPENCODE
|
|
72423
73163
|
else "_cursor_executable"
|
|
72424
73164
|
)
|
|
72425
73165
|
sess[executable_key] = runtime_status.get("_executable")
|
|
@@ -72733,7 +73473,7 @@ async def _start_turn_locked(
|
|
|
72733
73473
|
else None
|
|
72734
73474
|
),
|
|
72735
73475
|
)
|
|
72736
|
-
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):
|
|
72737
73477
|
provider_authority_context += (
|
|
72738
73478
|
"\n\n[AgentsDock final-result handoff]\n"
|
|
72739
73479
|
"Your successful non-empty final answer will be delivered once to the explicitly referenced chat. "
|
|
@@ -72763,7 +73503,7 @@ async def _start_turn_locked(
|
|
|
72763
73503
|
prompt,
|
|
72764
73504
|
)
|
|
72765
73505
|
provider_prompt = provider_turn_payload.user_prompt
|
|
72766
|
-
if backend
|
|
73506
|
+
if backend in {BACKEND_CURSOR, BACKEND_GEMINI} or (
|
|
72767
73507
|
backend == BACKEND_OPENCODE and resolved_provider_command is None
|
|
72768
73508
|
):
|
|
72769
73509
|
# These print-mode CLIs have no separate system/application-context
|
|
@@ -72833,6 +73573,8 @@ async def _start_turn_locked(
|
|
|
72833
73573
|
else:
|
|
72834
73574
|
started_payload["model"] = sess.get("model")
|
|
72835
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
|
|
72836
73578
|
if queued_id:
|
|
72837
73579
|
started_payload["queued_id"] = queued_id
|
|
72838
73580
|
run_metadata = {
|
|
@@ -72912,6 +73654,7 @@ async def _start_turn_locked(
|
|
|
72912
73654
|
or mailbox_wake_claim is not None
|
|
72913
73655
|
or getattr(req, "shared_chat_id", None) is not None
|
|
72914
73656
|
or queued_id is not None
|
|
73657
|
+
or getattr(req, "client_request_id", None) is not None
|
|
72915
73658
|
)
|
|
72916
73659
|
else append_event(session_id, "turn_started", started_payload)
|
|
72917
73660
|
)
|
|
@@ -73085,6 +73828,21 @@ async def _start_turn_locked(
|
|
|
73085
73828
|
else {}
|
|
73086
73829
|
),
|
|
73087
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")
|
|
73088
73846
|
elif backend == BACKEND_OPENCODE:
|
|
73089
73847
|
task = run_opencode(
|
|
73090
73848
|
session_id,
|
|
@@ -76274,7 +77032,7 @@ def ensure_managed_update_service_cgroup_clear(
|
|
|
76274
77032
|
*,
|
|
76275
77033
|
service_cgroup: str | None = None,
|
|
76276
77034
|
) -> dict[str, Any]:
|
|
76277
|
-
"""
|
|
77035
|
+
"""Require no children, or prove this worker's stop policy preserves them."""
|
|
76278
77036
|
|
|
76279
77037
|
state = managed_update_service_cgroup_state(
|
|
76280
77038
|
service_cgroup=service_cgroup,
|
|
@@ -77375,6 +78133,7 @@ async def lifespan(app: FastAPI):
|
|
|
77375
78133
|
cleanup_abandoned_fork_provider_threads(startup_abandoned_fork_threads)
|
|
77376
78134
|
)
|
|
77377
78135
|
host_monitor_task = asyncio.create_task(host_monitor_loop())
|
|
78136
|
+
queue_checkpoint_task = asyncio.create_task(queue_checkpoint_loop())
|
|
77378
78137
|
history_search_task = asyncio.create_task(history_search_index_loop())
|
|
77379
78138
|
runtime_probe_task = asyncio.create_task(asyncio.to_thread(refresh_runtime_diagnostics, force=True))
|
|
77380
78139
|
if CONFIG_ENV_APPLIED:
|
|
@@ -77452,6 +78211,10 @@ async def lifespan(app: FastAPI):
|
|
|
77452
78211
|
cross_chat_expiry_task.cancel()
|
|
77453
78212
|
digest_recovery_task.cancel()
|
|
77454
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()
|
|
77455
78218
|
server_update_pending_waiter_task.cancel()
|
|
77456
78219
|
await bounded_shutdown_phase(
|
|
77457
78220
|
"startup-reconciliation",
|
|
@@ -77460,6 +78223,8 @@ async def lifespan(app: FastAPI):
|
|
|
77460
78223
|
cross_chat_expiry_task,
|
|
77461
78224
|
digest_recovery_task,
|
|
77462
78225
|
queue_recovery_task,
|
|
78226
|
+
queue_checkpoint_task,
|
|
78227
|
+
*history_tasks,
|
|
77463
78228
|
server_update_pending_waiter_task,
|
|
77464
78229
|
*(
|
|
77465
78230
|
(health_queue_reconcile_task,)
|
|
@@ -80761,6 +81526,22 @@ async def codex_provider_mcp(request: Request) -> Response:
|
|
|
80761
81526
|
})
|
|
80762
81527
|
|
|
80763
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
|
+
|
|
80764
81545
|
@app.get("/api/health")
|
|
80765
81546
|
async def health() -> dict[str, Any]:
|
|
80766
81547
|
await reconcile_idle_queued_turns_from_health_poll()
|
|
@@ -80779,6 +81560,7 @@ async def health() -> dict[str, Any]:
|
|
|
80779
81560
|
team_hub_capability = await team_hub_capability_for_health()
|
|
80780
81561
|
return {
|
|
80781
81562
|
"ok": True,
|
|
81563
|
+
"chat_readiness": queue_recovery_readiness(),
|
|
80782
81564
|
"server_version": SERVER_VERSION,
|
|
80783
81565
|
"api_contract_version": API_CONTRACT_VERSION,
|
|
80784
81566
|
"server_identity": server_identity(),
|
|
@@ -80800,7 +81582,7 @@ async def health() -> dict[str, Any]:
|
|
|
80800
81582
|
"side_questions": side_questions.capability(),
|
|
80801
81583
|
"codex_auth_v1": codex_auth.capability(available=bool(AGENT_TOKEN) and CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC),
|
|
80802
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},
|
|
80803
|
-
"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]},
|
|
80804
81586
|
"team_mail_hints_v1": SECURE_PEER_RUNTIME.team_mail_hint_capability(),
|
|
80805
81587
|
"team_mail_hints_v2": SECURE_PEER_RUNTIME.team_notification_hint_capability(),
|
|
80806
81588
|
"websocket_auth_v1": {
|
|
@@ -80815,6 +81597,7 @@ async def health() -> dict[str, Any]:
|
|
|
80815
81597
|
"event_protocol": EVENTS_WEBSOCKET_PROTOCOL,
|
|
80816
81598
|
"terminal_protocol": TERMINAL_WEBSOCKET_PROTOCOL,
|
|
80817
81599
|
},
|
|
81600
|
+
"event_stream_liveness_v1": {"available": True, "version": 1},
|
|
80818
81601
|
"team_hub_v1": team_hub_capability,
|
|
80819
81602
|
"team_hub_host_control_v1": team_hub_host_control_capability(),
|
|
80820
81603
|
"agent_team_mail_routes_v1": {
|
|
@@ -80921,16 +81704,19 @@ async def health() -> dict[str, Any]:
|
|
|
80921
81704
|
BACKEND_CODEX,
|
|
80922
81705
|
BACKEND_CLAUDE,
|
|
80923
81706
|
BACKEND_OPENCODE,
|
|
81707
|
+
BACKEND_GEMINI,
|
|
80924
81708
|
],
|
|
80925
81709
|
"client_capability_by_backend": {
|
|
80926
81710
|
BACKEND_CODEX: CODEX_INTERACTIVE_CLIENT_CAPABILITY,
|
|
80927
81711
|
BACKEND_CLAUDE: CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY,
|
|
80928
81712
|
BACKEND_OPENCODE: OPENCODE_PROVIDER_COMMANDS_CLIENT_CAPABILITY,
|
|
81713
|
+
BACKEND_GEMINI: GEMINI_CLIENT_CAPABILITY,
|
|
80929
81714
|
},
|
|
80930
81715
|
"supported_kinds_by_backend": {
|
|
80931
81716
|
BACKEND_CODEX: ["skill"],
|
|
80932
81717
|
BACKEND_CLAUDE: ["command"],
|
|
80933
81718
|
BACKEND_OPENCODE: ["skill"],
|
|
81719
|
+
BACKEND_GEMINI: ["skill"],
|
|
80934
81720
|
},
|
|
80935
81721
|
"opencode_scope": {
|
|
80936
81722
|
"discovery": "bounded_documented_default_skill_roots",
|
|
@@ -81001,6 +81787,25 @@ async def health() -> dict[str, Any]:
|
|
|
81001
81787
|
"max_client_frame_bytes": PORT_TUNNEL_MAX_CLIENT_FRAME_BYTES,
|
|
81002
81788
|
**port_tunnel_status,
|
|
81003
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
|
+
},
|
|
81004
81809
|
"opencode_backend": {
|
|
81005
81810
|
# ``available`` advertises the server contract. Runtime
|
|
81006
81811
|
# readiness is reported independently by
|
|
@@ -81156,6 +81961,7 @@ async def health() -> dict[str, Any]:
|
|
|
81156
81961
|
"max_active_alerts": EMERGENCY_ACTIVE_ALERT_LIMIT,
|
|
81157
81962
|
"stream_path": "/api/emergency-alerts/events",
|
|
81158
81963
|
"stream_protocol": EMERGENCY_WEBSOCKET_PROTOCOL,
|
|
81964
|
+
"acknowledge_through_alert_id": True,
|
|
81159
81965
|
},
|
|
81160
81966
|
"cross_chat_handoffs_v1": cross_chat_handoffs_capability(),
|
|
81161
81967
|
"codex_controls": {
|
|
@@ -82443,6 +83249,9 @@ app.include_router(provider_connections.create_router(
|
|
|
82443
83249
|
),
|
|
82444
83250
|
))
|
|
82445
83251
|
|
|
83252
|
+
from gemini_runner import router as gemini_router
|
|
83253
|
+
app.include_router(gemini_router(sys.modules[__name__]))
|
|
83254
|
+
|
|
82446
83255
|
app.include_router(codex_provider.create_router(
|
|
82447
83256
|
authorize=require_native_admin_control,
|
|
82448
83257
|
store=CODEX_PROVIDER_STORE,
|
|
@@ -82863,6 +83672,7 @@ async def submit_interactive_chat_prompt(
|
|
|
82863
83672
|
result = await post_turn(session_id, TurnRequest(
|
|
82864
83673
|
prompt=prompt, file_ids=file_ids, shared_chat_id=share_id,
|
|
82865
83674
|
shared_chat_request_id=request_id,
|
|
83675
|
+
client_capabilities=(["gemini_acp_v1"] if (STORE.sessions.get(session_id) or {}).get("backend") == "gemini" else []),
|
|
82866
83676
|
))
|
|
82867
83677
|
if not isinstance(result, dict) or type(result.get("queued")) is not bool:
|
|
82868
83678
|
raise HTTPException(503, "Message acceptance could not be confirmed")
|
|
@@ -82969,13 +83779,21 @@ async def interactive_chat_native_snapshot(session_id: str) -> dict[str, Any]:
|
|
|
82969
83779
|
projected[key] = strip_agentsdock_generated_user_text(projected[key], expected_session_id=session_id)
|
|
82970
83780
|
queue.append(projected)
|
|
82971
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"])]}
|
|
82972
83788
|
snapshot = shared_native_value({
|
|
82973
83789
|
"session": public, "events": page["events"], "queue": queue, "active": active,
|
|
82974
83790
|
"hasMoreEvents": page["has_more"], "nextTimelineBefore": page.get("next_before"),
|
|
82975
83791
|
"eventsTotal": page.get("semantic_total"), "goal": goal,
|
|
82976
83792
|
"jobs": (await list_session_jobs(session_id))["jobs"],
|
|
82977
83793
|
"codex_runtime": codex_runtime, "claude_runtime": claude_runtime,
|
|
83794
|
+
**gemini_fields,
|
|
82978
83795
|
"health": {"capabilities": {
|
|
83796
|
+
**({"gemini_backend": {"available": True, "version": 1}} if backend == "gemini" else {}),
|
|
82979
83797
|
"codex_controls": {"available": codex_available},
|
|
82980
83798
|
"claude_controls": {"available": claude_available,
|
|
82981
83799
|
"interactive_client_capability": CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY},
|
|
@@ -83006,6 +83824,7 @@ async def interactive_chat_native_snapshot(session_id: str) -> dict[str, Any]:
|
|
|
83006
83824
|
async def steer_interactive_chat_prompt(session_id: str, prompt: str, *, share_id: str, request_id: str) -> dict[str, Any]:
|
|
83007
83825
|
receipt = await post_turn(session_id, TurnRequest(
|
|
83008
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 []),
|
|
83009
83828
|
))
|
|
83010
83829
|
if not isinstance(receipt, dict) or type(receipt.get("queued")) is not bool:
|
|
83011
83830
|
raise HTTPException(503, "Message acceptance could not be confirmed")
|
|
@@ -83102,6 +83921,9 @@ async def control_interactive_chat(session_id: str, action: str, payload: dict[s
|
|
|
83102
83921
|
INTERACTIVE_CHAT_CATALOG = (time.monotonic(), shared_native_value(await runtime_catalog()))
|
|
83103
83922
|
value = INTERACTIVE_CHAT_CATALOG[1]
|
|
83104
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)
|
|
83105
83927
|
callbacks = {
|
|
83106
83928
|
"turn.stop": stop_turn_endpoint, "turn.steer": steer_interactive_chat_prompt,
|
|
83107
83929
|
"queue.run_now": post_run_queued_turn_now, "queue.edit": patch_queued_turn,
|
|
@@ -83111,6 +83933,7 @@ async def control_interactive_chat(session_id: str, action: str, payload: dict[s
|
|
|
83111
83933
|
"job.create": create_session_job, "job.update": update_session_job,
|
|
83112
83934
|
"job.toggle": update_session_job, "job.delete": delete_session_job, "job.run": run_interactive_chat_job,
|
|
83113
83935
|
"approval.codex": post_codex_interaction_response, "approval.claude": post_claude_interaction_response,
|
|
83936
|
+
"approval.gemini": shared_gemini_approval,
|
|
83114
83937
|
}
|
|
83115
83938
|
models = {model.__name__: model for model in (
|
|
83116
83939
|
UpdateSessionRequest, CodexGoalRequest, CreateScopedJobRequest, UpdateJobRequest,
|
|
@@ -85903,6 +86726,7 @@ async def create_session_with_history(req: CreateSessionRequest) -> dict[str, An
|
|
|
85903
86726
|
async def get_local_sessions(
|
|
85904
86727
|
limit: int = Query(default=200, ge=1, le=MAX_LOCAL_SESSION_LIST_ITEMS),
|
|
85905
86728
|
include_cursor: bool = False,
|
|
86729
|
+
include_gemini: bool = False,
|
|
85906
86730
|
) -> dict[str, Any]:
|
|
85907
86731
|
"""List main conversations unused by any installed same-user local instance."""
|
|
85908
86732
|
|
|
@@ -85913,6 +86737,8 @@ async def get_local_sessions(
|
|
|
85913
86737
|
for key in local_session_ownership.provider_session_keys(session, DEFAULT_BACKEND)
|
|
85914
86738
|
}
|
|
85915
86739
|
options = {"include_cursor": True} if include_cursor else {}
|
|
86740
|
+
if include_gemini:
|
|
86741
|
+
options["include_gemini"] = True
|
|
85916
86742
|
sessions = await asyncio.to_thread(local_session_candidates, limit, known_provider_keys, **options)
|
|
85917
86743
|
return {"sessions": sessions}
|
|
85918
86744
|
|
|
@@ -85986,6 +86812,7 @@ async def bulk_import_sessions_guarded(
|
|
|
85986
86812
|
MAX_LOCAL_SESSION_LIST_ITEMS,
|
|
85987
86813
|
set(),
|
|
85988
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 {}),
|
|
85989
86816
|
)
|
|
85990
86817
|
candidate_by_key = {
|
|
85991
86818
|
(str(candidate["backend"]), str(candidate["provider_session_id"])): candidate
|
|
@@ -85995,6 +86822,10 @@ async def bulk_import_sessions_guarded(
|
|
|
85995
86822
|
results: list[dict[str, Any]] = []
|
|
85996
86823
|
for item in req.items:
|
|
85997
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
|
|
85998
86829
|
if key in requested_keys:
|
|
85999
86830
|
results.append(bulk_import_result(
|
|
86000
86831
|
item,
|
|
@@ -86035,7 +86866,11 @@ async def bulk_import_sessions_guarded(
|
|
|
86035
86866
|
|
|
86036
86867
|
staged_session: dict[str, Any] | None = None
|
|
86037
86868
|
try:
|
|
86038
|
-
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:
|
|
86039
86874
|
source_path, history_items = await asyncio.to_thread(
|
|
86040
86875
|
cursor_initial_history, item.provider_session_id, candidate_cwd or "",
|
|
86041
86876
|
)
|
|
@@ -86077,6 +86912,7 @@ async def bulk_import_sessions_guarded(
|
|
|
86077
86912
|
staged_session = await STORE.create(
|
|
86078
86913
|
CreateSessionRequest(
|
|
86079
86914
|
backend=item.backend,
|
|
86915
|
+
provider_connection=item.provider_connection,
|
|
86080
86916
|
provider_session_id=item.provider_session_id,
|
|
86081
86917
|
cwd=candidate_cwd or DEFAULT_CWD,
|
|
86082
86918
|
title=(item.title or str(candidate.get("label") or "")).strip()
|
|
@@ -86353,8 +87189,6 @@ async def get_session(
|
|
|
86353
87189
|
normalized_page_mode = str(page_mode or "").strip().lower()
|
|
86354
87190
|
if normalized_page_mode not in {"", "semantic"}:
|
|
86355
87191
|
raise HTTPException(status_code=400, detail="page_mode must be semantic")
|
|
86356
|
-
if normalized_page_mode != "semantic":
|
|
86357
|
-
await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
|
|
86358
87192
|
page_tail = tail and after <= 0
|
|
86359
87193
|
semantic_page: dict[str, Any] | None = None
|
|
86360
87194
|
if normalized_page_mode == "semantic":
|
|
@@ -86420,6 +87254,23 @@ async def get_session(
|
|
|
86420
87254
|
return response
|
|
86421
87255
|
|
|
86422
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
|
+
|
|
86423
87274
|
@app.post("/api/sessions/{session_id}/import-history")
|
|
86424
87275
|
async def import_history(session_id: str, req: ImportHistoryRequest) -> dict[str, Any]:
|
|
86425
87276
|
async with session_lifecycle_lock(session_id):
|
|
@@ -86715,6 +87566,68 @@ async def ensure_backend_update_allowed(
|
|
|
86715
87566
|
|
|
86716
87567
|
@app.patch("/api/sessions/{session_id}")
|
|
86717
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")
|
|
86718
87631
|
ensure_session_not_initializing(session_id)
|
|
86719
87632
|
patch = req.model_dump(exclude_unset=True)
|
|
86720
87633
|
if SESSION_LIFECYCLE_UPDATE_FIELDS.intersection(patch):
|
|
@@ -86722,6 +87635,12 @@ async def update_session(session_id: str, req: UpdateSessionRequest) -> dict[str
|
|
|
86722
87635
|
current = STORE.sessions.get(session_id)
|
|
86723
87636
|
if not current:
|
|
86724
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)}
|
|
86725
87644
|
if req.archived is False and current.get("archived"):
|
|
86726
87645
|
try:
|
|
86727
87646
|
# An earlier archive may have failed to save jobs.json
|
|
@@ -88583,7 +89502,7 @@ async def _put_codex_goal_locked(
|
|
|
88583
89502
|
if req.status == "active":
|
|
88584
89503
|
if (STORE.sessions.get(session_id) or {}).get("archived"):
|
|
88585
89504
|
raise HTTPException(status_code=409, detail="archived chats cannot resume goals")
|
|
88586
|
-
await wait_for_queue_recovery_admission()
|
|
89505
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
88587
89506
|
manager, thread_id, control_session = await acquire_codex_control_thread(
|
|
88588
89507
|
session_id,
|
|
88589
89508
|
reserve_session=req.status == "active",
|
|
@@ -89370,6 +90289,26 @@ async def acknowledge_session_emergency(
|
|
|
89370
90289
|
sess = STORE.sessions.get(session_id)
|
|
89371
90290
|
if not sess:
|
|
89372
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)}
|
|
89373
90312
|
alert_id = req.expected_alert_id
|
|
89374
90313
|
acknowledged_event = await asyncio.to_thread(
|
|
89375
90314
|
find_emergency_acknowledgement_event,
|
|
@@ -89535,6 +90474,13 @@ async def discard_empty_session(session_id: str, expected_updated_at: str) -> di
|
|
|
89535
90474
|
async def delete_session(session_id: str, discard_updated_at: str | None = None) -> dict[str, Any]:
|
|
89536
90475
|
async with session_lifecycle_lock(session_id):
|
|
89537
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.")
|
|
89538
90484
|
if session_id in DELETED_SESSION_TOMBSTONES:
|
|
89539
90485
|
# A previous delete can have committed before its request was
|
|
89540
90486
|
# canceled. Retry any tracked bridge retirement on idempotent
|
|
@@ -89702,6 +90648,16 @@ async def delete_session(session_id: str, discard_updated_at: str | None = None)
|
|
|
89702
90648
|
"shortly."
|
|
89703
90649
|
),
|
|
89704
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
|
|
89705
90661
|
elif proc:
|
|
89706
90662
|
await terminate_process_tree(proc)
|
|
89707
90663
|
|
|
@@ -89800,6 +90756,17 @@ async def delete_session(session_id: str, discard_updated_at: str | None = None)
|
|
|
89800
90756
|
# initial session record but before its task acknowledges stop.
|
|
89801
90757
|
# The task registry is now drained, so this reread is stable.
|
|
89802
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
|
|
89803
90770
|
late_provider_thread_id = str(
|
|
89804
90771
|
session_provider_id(late_session) or ""
|
|
89805
90772
|
)
|
|
@@ -90095,7 +91062,15 @@ def claude_completed_fork_boundary(
|
|
|
90095
91062
|
# final projected message can identify the inclusive native cutoff.
|
|
90096
91063
|
expected_text = projected_text if projected_text and final_text.endswith(projected_text) else final_text
|
|
90097
91064
|
recorded_uuid = str(final_projection.get("provider_message_id") or "") if expected_text == projected_text else ""
|
|
90098
|
-
|
|
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
|
+
))
|
|
90099
91074
|
started = next((
|
|
90100
91075
|
event for event in reversed(events[:-1])
|
|
90101
91076
|
if event.get("type") == "turn_started" and str(event.get("run_id") or "") == run_id
|
|
@@ -90116,7 +91091,11 @@ def claude_completed_fork_boundary(
|
|
|
90116
91091
|
if not record_uuid or (recorded_uuid and record_uuid != recorded_uuid):
|
|
90117
91092
|
continue
|
|
90118
91093
|
record_time = parse_job_timestamp(str(record.get("timestamp") or ""))
|
|
90119
|
-
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
|
+
):
|
|
90120
91099
|
continue
|
|
90121
91100
|
if not recorded_uuid and (start_time is None or record_time < start_time):
|
|
90122
91101
|
continue
|
|
@@ -90149,6 +91128,8 @@ async def fork_session(session_id: str, req: ForkSessionRequest) -> dict[str, An
|
|
|
90149
91128
|
async with session_lifecycle_lock(session_id):
|
|
90150
91129
|
try:
|
|
90151
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.")
|
|
90152
91133
|
if (STORE.sessions.get(session_id) or {}).get("backend") == BACKEND_OPENCODE:
|
|
90153
91134
|
raise HTTPException(status_code=409, detail="OpenCode native chat fork is unavailable; no transcript-copy fallback is enabled.")
|
|
90154
91135
|
async with ACTIVE_LOCK:
|
|
@@ -90561,8 +91542,123 @@ async def _fork_session_locked(
|
|
|
90561
91542
|
return {"session": public_session(child), "sessions": [public_session(sess) for sess in ordered_sessions]}
|
|
90562
91543
|
|
|
90563
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
|
+
|
|
90564
91654
|
@app.post("/api/sessions/{session_id}/turns")
|
|
90565
|
-
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]:
|
|
90566
91662
|
while True:
|
|
90567
91663
|
try:
|
|
90568
91664
|
return await start_turn(session_id, req)
|
|
@@ -94032,6 +95128,12 @@ async def stop_turn(
|
|
|
94032
95128
|
"superseded": True,
|
|
94033
95129
|
"message": "The requested run no longer owns this chat.",
|
|
94034
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."}
|
|
94035
95137
|
if preserve_active_goal and codex_goal_followup_requires_native(
|
|
94036
95138
|
STORE.sessions.get(session_id) or {}, active or {}, current_turn,
|
|
94037
95139
|
):
|
|
@@ -94754,7 +95856,20 @@ async def stop_turn(
|
|
|
94754
95856
|
# The app-server process may serve multiple Codex chats and must never be
|
|
94755
95857
|
# terminated as a per-chat Stop action.
|
|
94756
95858
|
if proc and active.get("transport") != CODEX_TRANSPORT_APP_SERVER:
|
|
94757
|
-
|
|
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)
|
|
94758
95873
|
if transition_ready is not None:
|
|
94759
95874
|
try:
|
|
94760
95875
|
await asyncio.wait_for(
|
|
@@ -94822,6 +95937,18 @@ async def stop_turn(
|
|
|
94822
95937
|
and not owner_task.done()
|
|
94823
95938
|
):
|
|
94824
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}
|
|
94825
95952
|
if current is not active:
|
|
94826
95953
|
if not goal_pause_ok:
|
|
94827
95954
|
return await terminal_goal_pause_failure_result(native_interrupt)
|
|
@@ -95667,6 +96794,17 @@ async def session_terminal(
|
|
|
95667
96794
|
await ws.close()
|
|
95668
96795
|
|
|
95669
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
|
+
|
|
95670
96808
|
@app.websocket("/api/sessions/{session_id}/events")
|
|
95671
96809
|
async def session_events(
|
|
95672
96810
|
session_id: str,
|
|
@@ -95675,6 +96813,8 @@ async def session_events(
|
|
|
95675
96813
|
visible: bool | None = None,
|
|
95676
96814
|
reasoning_stream: bool = False,
|
|
95677
96815
|
reasoning_text: bool = False,
|
|
96816
|
+
liveness: bool = False,
|
|
96817
|
+
history_projection: bool = False,
|
|
95678
96818
|
) -> None:
|
|
95679
96819
|
selected_subprotocol = websocket_endpoint_subprotocol(
|
|
95680
96820
|
ws,
|
|
@@ -95689,6 +96829,7 @@ async def session_events(
|
|
|
95689
96829
|
await ws.close(code=4404)
|
|
95690
96830
|
return
|
|
95691
96831
|
await ws.accept(subprotocol=selected_subprotocol)
|
|
96832
|
+
schedule_provider_history_sync(STORE.sessions[session_id])
|
|
95692
96833
|
try:
|
|
95693
96834
|
if not await HUB.reserve(session_id, ws):
|
|
95694
96835
|
await close_event_websocket_over_capacity(ws)
|
|
@@ -95704,7 +96845,6 @@ async def session_events(
|
|
|
95704
96845
|
# is ready. Reading its fsynced rows earlier can replay raw scheduled
|
|
95705
96846
|
# inputs before this socket is registered for the repaired broadcast.
|
|
95706
96847
|
async with event_delivery_lock(session_id):
|
|
95707
|
-
await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
|
|
95708
96848
|
boundary = await asyncio.to_thread(
|
|
95709
96849
|
last_event_seq_from_file,
|
|
95710
96850
|
events_path(session_id),
|
|
@@ -95755,12 +96895,28 @@ async def session_events(
|
|
|
95755
96895
|
return
|
|
95756
96896
|
if reasoning_snapshot is not None:
|
|
95757
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)
|
|
95758
96904
|
break
|
|
95759
96905
|
boundary = gap_boundary
|
|
95760
96906
|
while True:
|
|
95761
|
-
|
|
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")
|
|
95762
96916
|
except WebSocketDisconnect:
|
|
95763
96917
|
pass
|
|
96918
|
+
except (asyncio.TimeoutError, ConnectionError):
|
|
96919
|
+
await close_event_websocket_after_delivery_failure(ws)
|
|
95764
96920
|
finally:
|
|
95765
96921
|
await HUB.unsubscribe(session_id, ws)
|
|
95766
96922
|
|