@agentsdock/server 1.0.11 → 1.0.12-beta.1
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/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +1555 -346
- package/server/agentsdock_notify.py +123 -0
- package/server/chat_mailbox.py +12 -7
- package/server/claude_sdk_client.py +585 -317
- package/server/codex_app_server.py +19 -10
- package/server/codex_side_question.py +85 -7
- package/server/cursor_agent_client.py +22 -2
- package/server/install.sh +4 -2
- package/server/public_chat_transcript.py +11 -3
- package/server/session_unread.py +184 -0
- package/server/side_questions.py +46 -14
|
@@ -2369,17 +2369,20 @@ class CodexAppServerClient:
|
|
|
2369
2369
|
validated["turn"] = _protocol_turn(method, result.get("turn"))
|
|
2370
2370
|
return validated
|
|
2371
2371
|
|
|
2372
|
-
async def run_thread_shell_command(
|
|
2372
|
+
async def run_thread_shell_command(
|
|
2373
|
+
self, thread_id: str, command: str, *,
|
|
2374
|
+
before_send: Callable[[], bool] | None = None,
|
|
2375
|
+
) -> None:
|
|
2373
2376
|
thread_id = _require_nonempty_string(thread_id, "thread_id")
|
|
2374
2377
|
command = _require_nonempty_string(command, "command")
|
|
2375
2378
|
method = "thread/shellCommand"
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
await self.request(
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
)
|
|
2382
|
-
)
|
|
2379
|
+
params = {"threadId": thread_id, "command": command}
|
|
2380
|
+
if before_send is None:
|
|
2381
|
+
result = await self.request(method, params)
|
|
2382
|
+
else:
|
|
2383
|
+
await self.start()
|
|
2384
|
+
result = await self._request_connected(method, params, before_send=before_send)
|
|
2385
|
+
_protocol_empty_object(method, result)
|
|
2383
2386
|
|
|
2384
2387
|
async def list_background_terminals(
|
|
2385
2388
|
self,
|
|
@@ -3011,8 +3014,14 @@ class CodexAppServerManager:
|
|
|
3011
3014
|
delivery=delivery,
|
|
3012
3015
|
)
|
|
3013
3016
|
|
|
3014
|
-
async def run_thread_shell_command(
|
|
3015
|
-
|
|
3017
|
+
async def run_thread_shell_command(
|
|
3018
|
+
self, thread_id: str, command: str, *,
|
|
3019
|
+
before_send: Callable[[], bool] | None = None,
|
|
3020
|
+
) -> None:
|
|
3021
|
+
if before_send is None:
|
|
3022
|
+
await self.client.run_thread_shell_command(thread_id, command)
|
|
3023
|
+
else:
|
|
3024
|
+
await self.client.run_thread_shell_command(thread_id, command, before_send=before_send)
|
|
3016
3025
|
|
|
3017
3026
|
async def list_background_terminals(
|
|
3018
3027
|
self,
|
|
@@ -23,7 +23,7 @@ SIDE_INSTRUCTIONS = """Answer questions and explore in this separate side chat w
|
|
|
23
23
|
|
|
24
24
|
Treat inherited messages, tasks, plans, tool calls and approvals as reference material, not current instructions or permission. Follow only requests made in this side chat; do not resume unfinished parent work.
|
|
25
25
|
|
|
26
|
-
Use the thread's existing permissions and available tools, including external tools
|
|
26
|
+
Use the thread's existing permissions and available tools, including external tools and subagents. Delegate side-chat work only to subagents created in this side chat; inherited parent agents belong to the main conversation.
|
|
27
27
|
|
|
28
28
|
Change files, git state, configuration, permissions or other workspace state only when explicitly requested here. Request escalation only when such an explicit mutation requires it. Keep authorized changes limited to the request and preserve ongoing parent work."""
|
|
29
29
|
|
|
@@ -68,8 +68,6 @@ def side_chat_config(parent_config: dict | None = None) -> dict:
|
|
|
68
68
|
if isinstance(servers, dict):
|
|
69
69
|
servers.pop(name, None)
|
|
70
70
|
config.update({
|
|
71
|
-
"features.multi_agent": False, "features.multi_agent_v2": False,
|
|
72
|
-
"agents.enabled": False,
|
|
73
71
|
f"{prefix}.enabled": False, f"{prefix}.required": False,
|
|
74
72
|
# Codex validates transport shape even for a disabled server. Never
|
|
75
73
|
# copy the parent's authenticated helper URL/headers into this child.
|
|
@@ -182,6 +180,9 @@ class NativeCodexSideChat:
|
|
|
182
180
|
self._progress = None
|
|
183
181
|
self._lock = asyncio.Lock()
|
|
184
182
|
self._closed = False
|
|
183
|
+
self._thread_parents: dict[str, str] = {}
|
|
184
|
+
self._owned_threads: set[str] = set()
|
|
185
|
+
self._work_revision = 0
|
|
185
186
|
|
|
186
187
|
@property
|
|
187
188
|
def closed(self) -> bool:
|
|
@@ -193,12 +194,88 @@ class NativeCodexSideChat:
|
|
|
193
194
|
return None
|
|
194
195
|
return deepcopy(list(self._progress.items.values()))
|
|
195
196
|
|
|
197
|
+
def owns_thread(self, thread_id: str) -> bool:
|
|
198
|
+
"""Only the side fork and provider-verified descendants own approvals."""
|
|
199
|
+
return bool(not self._closed and self.thread_id and thread_id != self.parent_thread_id
|
|
200
|
+
and (thread_id == self.thread_id or thread_id in self._owned_threads))
|
|
201
|
+
|
|
202
|
+
def _remember_thread(self, thread):
|
|
203
|
+
if not isinstance(thread, dict):
|
|
204
|
+
return
|
|
205
|
+
thread_id = thread.get("id")
|
|
206
|
+
source = thread.get("source")
|
|
207
|
+
subagent = source.get("subAgent") if isinstance(source, dict) else None
|
|
208
|
+
spawn = subagent.get("thread_spawn") if isinstance(subagent, dict) else None
|
|
209
|
+
parent = spawn.get("parent_thread_id") if isinstance(spawn, dict) else None
|
|
210
|
+
if isinstance(thread_id, str) and isinstance(parent, str) and parent:
|
|
211
|
+
self._thread_parents[thread_id] = parent
|
|
212
|
+
|
|
213
|
+
def _remember_notification(self, notification):
|
|
214
|
+
if notification.get("method") in {"thread/started", "thread/closed", "thread/status/changed", "turn/started", "turn/completed"}:
|
|
215
|
+
self._work_revision += 1
|
|
216
|
+
if notification.get("method") == "thread/started":
|
|
217
|
+
self._remember_thread((notification.get("params") or {}).get("thread"))
|
|
218
|
+
|
|
219
|
+
def background_work_scope(self):
|
|
220
|
+
client = self._client
|
|
221
|
+
if self._closed or client is None or not client.ready or not self.thread_id:
|
|
222
|
+
return None
|
|
223
|
+
return (self, client, client.generation, self.thread_id, self._work_revision)
|
|
224
|
+
|
|
225
|
+
async def has_background_work(self):
|
|
226
|
+
scope = self.background_work_scope()
|
|
227
|
+
if scope is None:
|
|
228
|
+
return False
|
|
229
|
+
_owner, client, _generation, thread_id, _revision = scope
|
|
230
|
+
descendants = await client.list_descendant_threads(thread_id)
|
|
231
|
+
if self.background_work_scope() != scope:
|
|
232
|
+
raise SideQuestionError(409, "Side chat changed during background inspection")
|
|
233
|
+
# This native relationship-filtered API sees descendants even when
|
|
234
|
+
# app-server omitted their thread/started notification.
|
|
235
|
+
for child in descendants:
|
|
236
|
+
child_id = child.get("id")
|
|
237
|
+
status = child.get("status")
|
|
238
|
+
kind = status.get("type") if isinstance(status, dict) else None
|
|
239
|
+
if child_id in {thread_id, self.parent_thread_id} or not isinstance(child_id, str):
|
|
240
|
+
raise SideQuestionError(502, "Codex returned an invalid side descendant")
|
|
241
|
+
if kind == "active":
|
|
242
|
+
return True
|
|
243
|
+
if kind not in {"idle", "notLoaded"}:
|
|
244
|
+
raise SideQuestionError(502, "Codex side descendant activity is unknown")
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
async def _verify_owned_thread(self, thread_id: str) -> bool:
|
|
248
|
+
if self.owns_thread(thread_id):
|
|
249
|
+
return True
|
|
250
|
+
if not self.thread_id or self._client is None or self._closed:
|
|
251
|
+
return False
|
|
252
|
+
path: set[str] = set()
|
|
253
|
+
current = thread_id
|
|
254
|
+
while current and current not in path and current != self.parent_thread_id:
|
|
255
|
+
if self.owns_thread(current):
|
|
256
|
+
self._owned_threads.update(path)
|
|
257
|
+
return True
|
|
258
|
+
path.add(current)
|
|
259
|
+
if current not in self._thread_parents:
|
|
260
|
+
try:
|
|
261
|
+
thread = await self._client.read_thread(current, include_turns=False)
|
|
262
|
+
except CodexAppServerError:
|
|
263
|
+
return False
|
|
264
|
+
if self._closed or not isinstance(thread, dict) or thread.get("id") != current:
|
|
265
|
+
return False
|
|
266
|
+
self._remember_thread(thread)
|
|
267
|
+
current = self._thread_parents.get(current, "")
|
|
268
|
+
return False
|
|
269
|
+
|
|
196
270
|
async def _handle_server_request(self, request_id, method, params):
|
|
197
|
-
#
|
|
198
|
-
#
|
|
271
|
+
# Provider lineage permits side-chat descendants without granting a
|
|
272
|
+
# parent's inherited tool request or unrelated thread any authority.
|
|
199
273
|
if (self.server_request_handler is not None and not self._closed
|
|
200
|
-
and self.
|
|
201
|
-
and params.get("threadId")
|
|
274
|
+
and self.thread_id is not None
|
|
275
|
+
and isinstance(params.get("threadId"), str)
|
|
276
|
+
and await self._verify_owned_thread(params["threadId"])
|
|
277
|
+
and not self._closed
|
|
278
|
+
and (self._active is not None or params["threadId"] != self.thread_id)):
|
|
202
279
|
return await self.server_request_handler(request_id, method, params)
|
|
203
280
|
return await decline_server_request(request_id, method, params)
|
|
204
281
|
|
|
@@ -241,6 +318,7 @@ class NativeCodexSideChat:
|
|
|
241
318
|
on_process_started=lambda _pid, _group: self._process_started.set(),
|
|
242
319
|
**client_options,
|
|
243
320
|
)
|
|
321
|
+
self._client.add_notification_handler(self._remember_notification)
|
|
244
322
|
await self._client.start()
|
|
245
323
|
if self._closed:
|
|
246
324
|
raise SideQuestionError(409, "Side chat was closed; open a new side chat")
|
|
@@ -18,6 +18,7 @@ draining; this module deliberately contains no process lifecycle state.
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
import hashlib
|
|
21
22
|
import json
|
|
22
23
|
import re
|
|
23
24
|
from typing import Any, Iterator
|
|
@@ -31,6 +32,13 @@ CURSOR_SESSION_IDENTIFIER_RE = re.compile(
|
|
|
31
32
|
r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$"
|
|
32
33
|
)
|
|
33
34
|
CURSOR_CALL_ID_MAX_CHARS = 240
|
|
35
|
+
# Cursor 2026.10.22 emits this compound correlation key even for a normal
|
|
36
|
+
# file-read tool. LF is part of the opaque ID, not a second stream event.
|
|
37
|
+
_CURSOR_UUID_PATTERN = r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"
|
|
38
|
+
_CURSOR_COMPOSITE_CALL_ID_RE = re.compile(
|
|
39
|
+
rf"call-{_CURSOR_UUID_PATTERN}-[0-9]+\nfc_{_CURSOR_UUID_PATTERN}_[0-9]+"
|
|
40
|
+
)
|
|
41
|
+
_CURSOR_ENCODED_CALL_ID_PREFIX = "cursor-call-sha256:"
|
|
34
42
|
CURSOR_LIFECYCLE_VALUE_MAX = 2_147_483_647
|
|
35
43
|
CURSOR_ANSI_ESCAPE_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
|
|
36
44
|
CURSOR_TOOL_SUCCESS_OUTCOMES = (
|
|
@@ -90,7 +98,13 @@ def canonical_cursor_session_id(value: Any) -> str | None:
|
|
|
90
98
|
|
|
91
99
|
|
|
92
100
|
def canonical_cursor_call_id(value: Any) -> str:
|
|
93
|
-
"""
|
|
101
|
+
"""Bound an opaque tool key and encode the observed compound CLI format.
|
|
102
|
+
|
|
103
|
+
Keep the complete key in the digest: stripping LF, keeping only one half,
|
|
104
|
+
or assigning a fresh ID per event could merge distinct tools or orphan a
|
|
105
|
+
completion. The encoded namespace is reserved so literal provider IDs
|
|
106
|
+
cannot collide with it. Arbitrary control characters remain invalid.
|
|
107
|
+
"""
|
|
94
108
|
|
|
95
109
|
if not isinstance(value, str):
|
|
96
110
|
raise CursorEventParseError("tool_call call_id must be a string")
|
|
@@ -98,11 +112,17 @@ def canonical_cursor_call_id(value: Any) -> str:
|
|
|
98
112
|
not value
|
|
99
113
|
or value != value.strip()
|
|
100
114
|
or len(value) > CURSOR_CALL_ID_MAX_CHARS
|
|
101
|
-
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
|
102
115
|
):
|
|
103
116
|
raise CursorEventParseError(
|
|
104
117
|
"tool_call call_id must be a bounded nonempty identifier"
|
|
105
118
|
)
|
|
119
|
+
composite = _CURSOR_COMPOSITE_CALL_ID_RE.fullmatch(value) is not None
|
|
120
|
+
if not composite and any(ord(character) < 32 or ord(character) == 127 for character in value):
|
|
121
|
+
raise CursorEventParseError(
|
|
122
|
+
"tool_call call_id must be a bounded nonempty identifier"
|
|
123
|
+
)
|
|
124
|
+
if composite or value.startswith(_CURSOR_ENCODED_CALL_ID_PREFIX):
|
|
125
|
+
return _CURSOR_ENCODED_CALL_ID_PREFIX + hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
|
|
106
126
|
return value
|
|
107
127
|
|
|
108
128
|
|
package/server/install.sh
CHANGED
|
@@ -1846,7 +1846,7 @@ if [[ "$TEAM_HUB_MODE" == "host" && "$TEAM_HUB_OPERATION_PENDING" != "true" \
|
|
|
1846
1846
|
fi
|
|
1847
1847
|
fi
|
|
1848
1848
|
|
|
1849
|
-
RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py codex_response_stream.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py gemini_acp_client.py gemini_history.py gemini_content.py gemini_import.py gemini_runtime.py gemini_runner.py gemini_permissions.py gemini_api.py cursor_process_guard.py transcript_reader.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_analytics_web.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py npm/agentsdock/pair.cjs npm/agentsdock/qr-renderer.cjs npm/agentsdock/QR-NOTICES.txt update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
|
|
1849
|
+
RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_notify.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py session_unread.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py codex_response_stream.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py gemini_acp_client.py gemini_history.py gemini_content.py gemini_import.py gemini_runtime.py gemini_runner.py gemini_permissions.py gemini_api.py cursor_process_guard.py transcript_reader.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_analytics_web.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py npm/agentsdock/pair.cjs npm/agentsdock/qr-renderer.cjs npm/agentsdock/QR-NOTICES.txt update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
|
|
1850
1850
|
RELEASE_DIRECTORIES=(agentsdock_team_hub)
|
|
1851
1851
|
TEAM_HUB_RELEASE_FILES=(
|
|
1852
1852
|
__init__.py
|
|
@@ -3753,11 +3753,13 @@ validate_staged_release_runtime() (
|
|
|
3753
3753
|
"$STAGE_DIR/agentsdock_chats.py" \
|
|
3754
3754
|
"$STAGE_DIR/chat_mailbox.py" \
|
|
3755
3755
|
"$STAGE_DIR/agentsdock_emergency.py" \
|
|
3756
|
+
"$STAGE_DIR/agentsdock_notify.py" \
|
|
3756
3757
|
"$STAGE_DIR/agentsdock_publish.py" \
|
|
3757
3758
|
"$STAGE_DIR/agentsdock_mail.py" \
|
|
3758
3759
|
"$STAGE_DIR/agentsdock_team.py" \
|
|
3759
3760
|
"$STAGE_DIR/provider_commands.py" \
|
|
3760
3761
|
"$STAGE_DIR/provider_usage.py" \
|
|
3762
|
+
"$STAGE_DIR/session_unread.py" \
|
|
3761
3763
|
"$STAGE_DIR/claude_sdk_client.py" \
|
|
3762
3764
|
"$STAGE_DIR/claude_model_catalog.py" \
|
|
3763
3765
|
"$STAGE_DIR/claude_goals.py" \
|
|
@@ -4132,7 +4134,7 @@ for name in "${TEAM_HUB_RELEASE_FILES[@]}"; do
|
|
|
4132
4134
|
"$SOURCE_DIR/agentsdock_team_hub/$name" \
|
|
4133
4135
|
"$STAGE_DIR/agentsdock_team_hub/$name"
|
|
4134
4136
|
done
|
|
4135
|
-
chmod 755 "$STAGE_DIR/agent_server.py" "$STAGE_DIR/agentsdock_jobs.py" "$STAGE_DIR/agentsdock_chats.py" "$STAGE_DIR/agentsdock_emergency.py" "$STAGE_DIR/agentsdock_publish.py" "$STAGE_DIR/agentsdock_mail.py" "$STAGE_DIR/agentsdock_team.py" "$STAGE_DIR/install.sh" "$STAGE_DIR/uninstall.sh" "$STAGE_DIR/update_runner.py"
|
|
4137
|
+
chmod 755 "$STAGE_DIR/agent_server.py" "$STAGE_DIR/agentsdock_jobs.py" "$STAGE_DIR/agentsdock_chats.py" "$STAGE_DIR/agentsdock_emergency.py" "$STAGE_DIR/agentsdock_notify.py" "$STAGE_DIR/agentsdock_publish.py" "$STAGE_DIR/agentsdock_mail.py" "$STAGE_DIR/agentsdock_team.py" "$STAGE_DIR/install.sh" "$STAGE_DIR/uninstall.sh" "$STAGE_DIR/update_runner.py"
|
|
4136
4138
|
chmod 755 "$STAGE_DIR/instances.sh"
|
|
4137
4139
|
|
|
4138
4140
|
if [[ "$PREPARE_ONLY" == "true" ]]; then
|
|
@@ -28,11 +28,19 @@ class PublicTranscriptError(ValueError):
|
|
|
28
28
|
def _is_goal_followup(event: dict) -> bool:
|
|
29
29
|
return bool(
|
|
30
30
|
event.get("type") == "turn_steered"
|
|
31
|
-
and event.get("native_goal_steer") is True
|
|
32
31
|
and event.get("native_steer") is True
|
|
33
32
|
and event.get("provider_user_authored") is True
|
|
34
|
-
and
|
|
35
|
-
|
|
33
|
+
and (
|
|
34
|
+
(event.get("native_goal_steer") is True
|
|
35
|
+
and event.get("backend") == "codex"
|
|
36
|
+
and event.get("purpose") == "codex_goal_resume")
|
|
37
|
+
or (event.get("native_followup") is True
|
|
38
|
+
and event.get("backend") == "claude"
|
|
39
|
+
and event.get("purpose") is None
|
|
40
|
+
and event.get("provider_generated") is not True
|
|
41
|
+
and isinstance(event.get("provider_message_id"), str)
|
|
42
|
+
and event["provider_message_id"].strip())
|
|
43
|
+
)
|
|
36
44
|
and isinstance(event.get("run_id"), str)
|
|
37
45
|
and event["run_id"].strip()
|
|
38
46
|
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Incremental unread provenance; journal recovery runs off the event loop."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Iterable
|
|
6
|
+
from transcript_reader import jsonl_records
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
COUNTER = "latest_non_job_agent_event_seq"
|
|
10
|
+
CHECKPOINT = "_non_job_agent_event_checkpoint_seq"
|
|
11
|
+
PENDING_COUNTER = "_non_job_agent_event_pending_seq"
|
|
12
|
+
LAST_RUN = "_non_job_agent_last_run"
|
|
13
|
+
JOURNAL = "_non_job_agent_event_journal"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def imported_event(event: dict[str, Any]) -> bool:
|
|
17
|
+
return event.get("imported") is True or str(event.get("run_id") or "").startswith("import_")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def scheduled_event(event: dict[str, Any]) -> bool:
|
|
21
|
+
job = event.get("job")
|
|
22
|
+
return bool(
|
|
23
|
+
event.get("purpose") == "scheduled_job"
|
|
24
|
+
or str(event.get("job_id") or "").strip()
|
|
25
|
+
or isinstance(job, dict) and str(job.get("id") or "").strip()
|
|
26
|
+
or str(event.get("type") or "").startswith("job_")
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def project_event(
|
|
31
|
+
session: dict[str, Any], event: dict[str, Any], *,
|
|
32
|
+
visible: bool, run_metadata: dict[str, Any],
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Apply an appended event without I/O or changing the ordinary read cursor."""
|
|
35
|
+
seq = int(event.get("seq") or 0)
|
|
36
|
+
if seq <= 0:
|
|
37
|
+
return
|
|
38
|
+
# Only a new journal can establish an empty baseline without recovery.
|
|
39
|
+
if seq == 1 and not int(session.get("latest_event_seq") or 0):
|
|
40
|
+
session.setdefault(COUNTER, 0)
|
|
41
|
+
session.setdefault(CHECKPOINT, 0)
|
|
42
|
+
known = isinstance(session.get(COUNTER), int) and CHECKPOINT in session
|
|
43
|
+
run_id = str(event.get("run_id") or "")
|
|
44
|
+
contexts: list[dict[str, Any]] = []
|
|
45
|
+
if event.get("type") == "turn_started" and run_id:
|
|
46
|
+
context = {"run_id": run_id, "scheduled_job": scheduled_event(event) or scheduled_event(run_metadata)}
|
|
47
|
+
session[LAST_RUN] = context
|
|
48
|
+
contexts.append(context)
|
|
49
|
+
else:
|
|
50
|
+
for candidate in (session.get("active_run"), session.get(LAST_RUN)):
|
|
51
|
+
if isinstance(candidate, dict) and run_id and candidate.get("run_id") == run_id:
|
|
52
|
+
contexts.append(candidate)
|
|
53
|
+
scheduled = scheduled_event(event) or scheduled_event(run_metadata) or any(
|
|
54
|
+
scheduled_event(context) or context.get("scheduled_job") is True for context in contexts
|
|
55
|
+
)
|
|
56
|
+
if scheduled and run_id and (session.get(LAST_RUN) or {}).get("run_id") == run_id:
|
|
57
|
+
session[LAST_RUN] = {"run_id": run_id, "scheduled_job": True}
|
|
58
|
+
if visible and not scheduled and not imported_event(event):
|
|
59
|
+
field = COUNTER if known else PENDING_COUNTER
|
|
60
|
+
session[field] = max(int(session.get(field) or 0), seq)
|
|
61
|
+
if known:
|
|
62
|
+
session[CHECKPOINT] = max(int(session.get(CHECKPOINT) or 0), seq)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def recover_projection(
|
|
66
|
+
path: Path, snapshot: dict[str, Any], *,
|
|
67
|
+
visible: Callable[[str, dict[str, Any]], bool],
|
|
68
|
+
resume_offset: Callable[[Path, int], int],
|
|
69
|
+
prior_events: Callable[[], Iterable[dict[str, Any]]],
|
|
70
|
+
cancelled: Callable[[], bool],
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""Project one fixed journal extent, then resolve missing run provenance once.
|
|
73
|
+
|
|
74
|
+
Legacy chats need one forward pass. A durable checkpoint resumes through the
|
|
75
|
+
existing sparse index; only an untagged tail run whose start precedes that
|
|
76
|
+
checkpoint requires a backwards provenance pass. No history is read by the
|
|
77
|
+
session-list endpoint or the live event projection.
|
|
78
|
+
"""
|
|
79
|
+
known = isinstance(snapshot.get(COUNTER), int) and CHECKPOINT in snapshot
|
|
80
|
+
after = int(snapshot.get(CHECKPOINT) or 0) if known else 0
|
|
81
|
+
latest = int(snapshot.get(COUNTER) or 0) if known else 0
|
|
82
|
+
through = after
|
|
83
|
+
last_run = dict(snapshot.get(LAST_RUN) or {})
|
|
84
|
+
runs: dict[str, dict[str, Any]] = {}
|
|
85
|
+
unresolved: dict[str, list[dict[str, Any]]] = {}
|
|
86
|
+
|
|
87
|
+
def finish(run_id: str, run: dict[str, Any]) -> None:
|
|
88
|
+
nonlocal latest
|
|
89
|
+
if run["scheduled"] or not run["visible_seq"]:
|
|
90
|
+
return
|
|
91
|
+
if run["origin"]:
|
|
92
|
+
latest = max(latest, run["visible_seq"])
|
|
93
|
+
else:
|
|
94
|
+
unresolved.setdefault(run_id, []).append(run)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
source = path.open("rb")
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
if after or int(snapshot.get("latest_event_seq") or 0):
|
|
100
|
+
raise
|
|
101
|
+
return {COUNTER: 0, CHECKPOINT: 0, LAST_RUN: {}}
|
|
102
|
+
with source:
|
|
103
|
+
extent = source.seek(0, 2)
|
|
104
|
+
inode = path.stat().st_ino
|
|
105
|
+
journal = snapshot.get(JOURNAL) or {}
|
|
106
|
+
if known and journal and (journal.get("inode") != inode or int(journal.get("offset") or 0) > extent):
|
|
107
|
+
after = latest = through = 0
|
|
108
|
+
last_run = {}
|
|
109
|
+
offset = int(journal.get("offset") or 0) if after and journal.get("inode") == inode else 0
|
|
110
|
+
source.seek(offset or (resume_offset(path, after) if after else 0))
|
|
111
|
+
complete_offset = source.tell()
|
|
112
|
+
for event, ending, raw in jsonl_records(
|
|
113
|
+
source, end=extent, project=False, require_newline=False, deadline=float("inf"), cancelled=cancelled,
|
|
114
|
+
max_records=float("inf"), max_nodes=None, max_depth=512,
|
|
115
|
+
retained_fields=frozenset({"seq", "type", "run_id", "purpose", "job_id", "job", "text", "result_text", "goal", "imported"}),
|
|
116
|
+
):
|
|
117
|
+
if not raw.complete:
|
|
118
|
+
break # Leave a concurrently written/crash fragment unclaimed.
|
|
119
|
+
complete_offset = ending
|
|
120
|
+
if not isinstance(event, dict):
|
|
121
|
+
continue
|
|
122
|
+
seq = int(event.get("seq") or 0)
|
|
123
|
+
if seq <= after:
|
|
124
|
+
continue
|
|
125
|
+
through = max(through, seq)
|
|
126
|
+
event_type = str(event.get("type") or "")
|
|
127
|
+
is_visible = visible(event_type, event) and not imported_event(event)
|
|
128
|
+
run_id = str(event.get("run_id") or "")
|
|
129
|
+
scheduled = scheduled_event(event)
|
|
130
|
+
if not run_id:
|
|
131
|
+
if is_visible and not scheduled:
|
|
132
|
+
latest = max(latest, seq)
|
|
133
|
+
continue
|
|
134
|
+
if event_type == "turn_started" and run_id in runs:
|
|
135
|
+
finish(run_id, runs.pop(run_id))
|
|
136
|
+
run = runs.setdefault(run_id, {"first_seq": seq, "visible_seq": 0, "scheduled": False, "origin": False})
|
|
137
|
+
run["scheduled"] = run["scheduled"] or scheduled
|
|
138
|
+
if event_type == "turn_started":
|
|
139
|
+
run["origin"] = True
|
|
140
|
+
last_run = {"run_id": run_id, "scheduled_job": scheduled}
|
|
141
|
+
elif scheduled and last_run.get("run_id") == run_id:
|
|
142
|
+
last_run["scheduled_job"] = True
|
|
143
|
+
if is_visible:
|
|
144
|
+
run["visible_seq"] = max(run["visible_seq"], seq)
|
|
145
|
+
if event_type in {"turn_finished", "turn_stopped"} and (run["scheduled"] or not run["visible_seq"]):
|
|
146
|
+
# Completed cron runs do not accumulate with the schedule's
|
|
147
|
+
# history. A later untagged event is resolved by the single
|
|
148
|
+
# backwards provenance pass, just like a restart tail.
|
|
149
|
+
runs.pop(run_id, None)
|
|
150
|
+
for run_id, run in runs.items():
|
|
151
|
+
finish(run_id, run)
|
|
152
|
+
# One reverse pass resolves all tail runs together, including stripped raw
|
|
153
|
+
# provider events and a job_ran marker emitted after the provider's start.
|
|
154
|
+
if unresolved:
|
|
155
|
+
for event in prior_events():
|
|
156
|
+
if cancelled():
|
|
157
|
+
raise InterruptedError("unread projection cancelled")
|
|
158
|
+
run_id = str(event.get("run_id") or "")
|
|
159
|
+
candidates = unresolved.get(run_id)
|
|
160
|
+
if not candidates:
|
|
161
|
+
continue
|
|
162
|
+
seq = int(event.get("seq") or 0)
|
|
163
|
+
scheduled = scheduled_event(event)
|
|
164
|
+
if not scheduled and event.get("type") != "turn_started":
|
|
165
|
+
continue
|
|
166
|
+
remaining = []
|
|
167
|
+
for run in candidates:
|
|
168
|
+
if seq >= run["first_seq"]:
|
|
169
|
+
remaining.append(run)
|
|
170
|
+
elif not scheduled:
|
|
171
|
+
latest = max(latest, run["visible_seq"])
|
|
172
|
+
if remaining:
|
|
173
|
+
unresolved[run_id] = remaining
|
|
174
|
+
else:
|
|
175
|
+
unresolved.pop(run_id)
|
|
176
|
+
if not unresolved:
|
|
177
|
+
break
|
|
178
|
+
# Legacy output with no recorded job provenance remains ordinary;
|
|
179
|
+
# merely owning a schedule never suppresses a human conversation.
|
|
180
|
+
for candidates in unresolved.values():
|
|
181
|
+
for run in candidates:
|
|
182
|
+
latest = max(latest, run["visible_seq"])
|
|
183
|
+
return {COUNTER: latest, CHECKPOINT: through, LAST_RUN: last_run,
|
|
184
|
+
JOURNAL: {"inode": inode, "offset": complete_offset}}
|
package/server/side_questions.py
CHANGED
|
@@ -27,10 +27,11 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
27
27
|
from fastapi.responses import JSONResponse
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
MAX_QUESTION_CHARS = 8000
|
|
31
30
|
MAX_HISTORY_ITEMS = 32
|
|
32
31
|
MAX_HISTORY_CHARS = 60000
|
|
33
32
|
MAX_REQUEST_BYTES = 512 * 1024
|
|
33
|
+
# The streamed body limit bounds input; avoid a second, smaller model-text cap.
|
|
34
|
+
MAX_QUESTION_CHARS = MAX_REQUEST_BYTES
|
|
34
35
|
MAX_CONTEXT_CHARS = 60000
|
|
35
36
|
MAX_LOG_BYTES = 4 * 1024 * 1024
|
|
36
37
|
MAX_OUTPUT_BYTES = 1024 * 1024
|
|
@@ -369,11 +370,6 @@ class _NativeConversation:
|
|
|
369
370
|
closed: bool = False
|
|
370
371
|
timer: object = None
|
|
371
372
|
close_task: asyncio.Task | None = None
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
NATIVE_IDLE_SECONDS = 30 * 60
|
|
375
|
-
|
|
376
|
-
|
|
377
373
|
class SideQuestions:
|
|
378
374
|
def __init__(self, answer=None, *, native_factory=None, storage_path=None, notify=None, admission_check=None, runtime_settings_for_session=None):
|
|
379
375
|
self.answer = answer
|
|
@@ -396,6 +392,43 @@ class SideQuestions:
|
|
|
396
392
|
def active_work_labels(self):
|
|
397
393
|
return [f"Side chat in {session_id}" for session_id in sorted(self.active_session_ids())]
|
|
398
394
|
|
|
395
|
+
def background_work_scopes(self):
|
|
396
|
+
handles = [(key[1], conversation.handle) for key, conversation in self.conversations.items()
|
|
397
|
+
if not conversation.closed and conversation.handle is not None]
|
|
398
|
+
if self.synced is not None:
|
|
399
|
+
handles.extend((key[1], handle) for key, handle in self.synced.handles.items())
|
|
400
|
+
scopes = []
|
|
401
|
+
for session_id, handle in handles:
|
|
402
|
+
inspect = getattr(handle, "background_work_scope", None)
|
|
403
|
+
scope = inspect() if callable(inspect) else None
|
|
404
|
+
if scope is not None:
|
|
405
|
+
scopes.append((session_id, handle, scope))
|
|
406
|
+
return tuple(scopes)
|
|
407
|
+
|
|
408
|
+
async def prepare_background_work_snapshot(self, *, timeout_seconds=5.0):
|
|
409
|
+
scopes = self.background_work_scopes()
|
|
410
|
+
|
|
411
|
+
async def inspect(session_id, handle, _scope):
|
|
412
|
+
try:
|
|
413
|
+
active = await handle.has_background_work()
|
|
414
|
+
except Exception:
|
|
415
|
+
return f"Side chat provider state unknown in {session_id}"
|
|
416
|
+
return f"Side chat background work in {session_id}" if active else None
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
values = await asyncio.wait_for(asyncio.gather(*(inspect(*scope) for scope in scopes)),
|
|
420
|
+
timeout=timeout_seconds)
|
|
421
|
+
labels = tuple(sorted(set(value for value in values if value)))
|
|
422
|
+
except asyncio.TimeoutError:
|
|
423
|
+
labels = tuple(f"Side chat provider state unknown in {session_id}" for session_id, _, _ in scopes)
|
|
424
|
+
return {"scopes": scopes, "labels": labels}
|
|
425
|
+
|
|
426
|
+
def background_work_labels_from_snapshot(self, snapshot):
|
|
427
|
+
scopes = self.background_work_scopes()
|
|
428
|
+
if snapshot.get("scopes", ()) != scopes:
|
|
429
|
+
return ["Side chat provider state changed during inspection"]
|
|
430
|
+
return list(snapshot.get("labels", ()))
|
|
431
|
+
|
|
399
432
|
async def _close_conversation(self, conversation):
|
|
400
433
|
conversation.closed = True
|
|
401
434
|
if conversation.timer is not None:
|
|
@@ -415,7 +448,7 @@ class SideQuestions:
|
|
|
415
448
|
await asyncio.shield(conversation.close_task)
|
|
416
449
|
|
|
417
450
|
def _expire_conversation(self, key, conversation):
|
|
418
|
-
if self.conversations.get(key) is not conversation or conversation.busy:
|
|
451
|
+
if self.conversations.get(key) is not conversation or conversation.busy or not conversation.closed:
|
|
419
452
|
return
|
|
420
453
|
self.conversations.pop(key, None)
|
|
421
454
|
task = asyncio.create_task(self._close_conversation(conversation), name="side-chat-expiry")
|
|
@@ -461,8 +494,9 @@ class SideQuestions:
|
|
|
461
494
|
raise
|
|
462
495
|
finally:
|
|
463
496
|
conversation.busy = False
|
|
464
|
-
conversation.
|
|
465
|
-
|
|
497
|
+
if conversation.closed:
|
|
498
|
+
conversation.timer = asyncio.get_running_loop().call_later(
|
|
499
|
+
RECEIPT_TTL_SECONDS, self._expire_conversation, key, conversation)
|
|
466
500
|
|
|
467
501
|
async def close_conversation(self, owner, session_id, side_chat_id):
|
|
468
502
|
key = (owner, session_id, side_chat_id)
|
|
@@ -473,7 +507,7 @@ class SideQuestions:
|
|
|
473
507
|
await self.cancel(*receipt_key)
|
|
474
508
|
await self._close_conversation(conversation)
|
|
475
509
|
conversation.timer = asyncio.get_running_loop().call_later(
|
|
476
|
-
|
|
510
|
+
RECEIPT_TTL_SECONDS, self._expire_conversation, key, conversation)
|
|
477
511
|
|
|
478
512
|
def _prune(self):
|
|
479
513
|
now = time.monotonic()
|
|
@@ -874,8 +908,6 @@ class SyncedSideChats:
|
|
|
874
908
|
self.cleanup_tasks.discard(cleanup)
|
|
875
909
|
if self.tasks.get(key) is asyncio.current_task():
|
|
876
910
|
self.tasks.pop(key, None)
|
|
877
|
-
if not self.stopping:
|
|
878
|
-
self.timers[key] = asyncio.get_running_loop().call_later(NATIVE_IDLE_SECONDS, self._expire, key)
|
|
879
911
|
|
|
880
912
|
async def _finish(self, key, side_chat_id, request_id, status, **result):
|
|
881
913
|
async with self._lock(key):
|
|
@@ -1053,7 +1085,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
|
|
|
1053
1085
|
if not isinstance(request_id, str) or not IDENTIFIER.fullmatch(request_id):
|
|
1054
1086
|
raise HTTPException(400, "Invalid side question request ID")
|
|
1055
1087
|
if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
|
|
1056
|
-
raise HTTPException(400, "Question must contain 1 to
|
|
1088
|
+
raise HTTPException(400, f"Question must contain 1 to {MAX_QUESTION_CHARS} characters")
|
|
1057
1089
|
try:
|
|
1058
1090
|
question.encode("utf-8")
|
|
1059
1091
|
except UnicodeEncodeError:
|
|
@@ -1152,7 +1184,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
|
|
|
1152
1184
|
raise HTTPException(400, "Invalid side conversation ID")
|
|
1153
1185
|
question = value["question"]
|
|
1154
1186
|
if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
|
|
1155
|
-
raise HTTPException(400, "Question must contain 1 to
|
|
1187
|
+
raise HTTPException(400, f"Question must contain 1 to {MAX_QUESTION_CHARS} characters")
|
|
1156
1188
|
try:
|
|
1157
1189
|
question.encode("utf-8")
|
|
1158
1190
|
except UnicodeEncodeError:
|