codex-autorunner 1.2.0__py3-none-any.whl → 1.3.0__py3-none-any.whl
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.
- codex_autorunner/bootstrap.py +26 -5
- codex_autorunner/core/about_car.py +12 -12
- codex_autorunner/core/config.py +178 -61
- codex_autorunner/core/context_awareness.py +1 -0
- codex_autorunner/core/filesystem.py +24 -0
- codex_autorunner/core/flows/controller.py +50 -12
- codex_autorunner/core/flows/runtime.py +8 -3
- codex_autorunner/core/hub.py +293 -16
- codex_autorunner/core/lifecycle_events.py +44 -5
- codex_autorunner/core/pma_context.py +188 -1
- codex_autorunner/core/pma_delivery.py +81 -0
- codex_autorunner/core/pma_dispatches.py +224 -0
- codex_autorunner/core/pma_lane_worker.py +122 -0
- codex_autorunner/core/pma_queue.py +167 -18
- codex_autorunner/core/pma_reactive.py +91 -0
- codex_autorunner/core/pma_safety.py +58 -0
- codex_autorunner/core/pma_sink.py +104 -0
- codex_autorunner/core/pma_transcripts.py +183 -0
- codex_autorunner/core/safe_paths.py +117 -0
- codex_autorunner/housekeeping.py +77 -23
- codex_autorunner/integrations/agents/codex_backend.py +18 -12
- codex_autorunner/integrations/agents/wiring.py +2 -0
- codex_autorunner/integrations/app_server/client.py +31 -0
- codex_autorunner/integrations/app_server/supervisor.py +3 -0
- codex_autorunner/integrations/telegram/adapter.py +1 -1
- codex_autorunner/integrations/telegram/config.py +1 -1
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
- codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
- codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
- codex_autorunner/integrations/telegram/handlers/messages.py +8 -2
- codex_autorunner/integrations/telegram/helpers.py +30 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
- codex_autorunner/static/archive.js +274 -81
- codex_autorunner/static/archiveApi.js +21 -0
- codex_autorunner/static/constants.js +1 -1
- codex_autorunner/static/docChatCore.js +2 -0
- codex_autorunner/static/hub.js +59 -0
- codex_autorunner/static/index.html +70 -54
- codex_autorunner/static/notificationBell.js +173 -0
- codex_autorunner/static/notifications.js +187 -36
- codex_autorunner/static/pma.js +96 -35
- codex_autorunner/static/styles.css +431 -4
- codex_autorunner/static/terminalManager.js +22 -3
- codex_autorunner/static/utils.js +5 -1
- codex_autorunner/surfaces/cli/cli.py +206 -129
- codex_autorunner/surfaces/cli/template_repos.py +157 -0
- codex_autorunner/surfaces/web/app.py +193 -5
- codex_autorunner/surfaces/web/routes/archive.py +197 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +115 -87
- codex_autorunner/surfaces/web/routes/flows.py +125 -67
- codex_autorunner/surfaces/web/routes/pma.py +638 -57
- codex_autorunner/surfaces/web/schemas.py +11 -0
- codex_autorunner/tickets/agent_pool.py +6 -1
- codex_autorunner/tickets/outbox.py +27 -14
- codex_autorunner/tickets/replies.py +4 -10
- codex_autorunner/tickets/runner.py +1 -0
- codex_autorunner/workspace/paths.py +8 -3
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +67 -57
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from .time_utils import now_iso
|
|
12
|
+
from .utils import atomic_write
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
PMA_TRANSCRIPTS_DIRNAME = "transcripts"
|
|
17
|
+
PMA_TRANSCRIPT_VERSION = 1
|
|
18
|
+
PMA_TRANSCRIPT_PREVIEW_CHARS = 400
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_pma_transcripts_dir(hub_root: Path) -> Path:
|
|
22
|
+
return hub_root / ".codex-autorunner" / "pma" / PMA_TRANSCRIPTS_DIRNAME
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _safe_segment(value: str) -> str:
|
|
26
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", (value or "").strip())
|
|
27
|
+
cleaned = cleaned.strip("-._")
|
|
28
|
+
if not cleaned:
|
|
29
|
+
return "unknown"
|
|
30
|
+
return cleaned[:120]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _stamp_now() -> str:
|
|
34
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_preview(path: Path) -> str:
|
|
38
|
+
if not path.exists():
|
|
39
|
+
return ""
|
|
40
|
+
try:
|
|
41
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
42
|
+
text = handle.read(PMA_TRANSCRIPT_PREVIEW_CHARS + 1)
|
|
43
|
+
except OSError as exc:
|
|
44
|
+
logger.warning("Failed to read PMA transcript content at %s: %s", path, exc)
|
|
45
|
+
return ""
|
|
46
|
+
text = text.strip()
|
|
47
|
+
if len(text) <= PMA_TRANSCRIPT_PREVIEW_CHARS:
|
|
48
|
+
return text
|
|
49
|
+
return text[:PMA_TRANSCRIPT_PREVIEW_CHARS].rstrip() + "..."
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class PmaTranscriptPointer:
|
|
54
|
+
turn_id: str
|
|
55
|
+
metadata_path: str
|
|
56
|
+
content_path: str
|
|
57
|
+
created_at: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PmaTranscriptStore:
|
|
61
|
+
def __init__(self, hub_root: Path) -> None:
|
|
62
|
+
self._root = hub_root
|
|
63
|
+
self._dir = default_pma_transcripts_dir(hub_root)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def dir(self) -> Path:
|
|
67
|
+
return self._dir
|
|
68
|
+
|
|
69
|
+
def write_transcript(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
turn_id: str,
|
|
73
|
+
metadata: dict[str, Any],
|
|
74
|
+
assistant_text: str,
|
|
75
|
+
) -> PmaTranscriptPointer:
|
|
76
|
+
safe_turn_id = _safe_segment(turn_id)
|
|
77
|
+
stamp = _stamp_now()
|
|
78
|
+
base = f"{stamp}_{safe_turn_id}"
|
|
79
|
+
json_path = self._dir / f"{base}.json"
|
|
80
|
+
md_path = self._dir / f"{base}.md"
|
|
81
|
+
|
|
82
|
+
payload = dict(metadata)
|
|
83
|
+
payload.setdefault("version", PMA_TRANSCRIPT_VERSION)
|
|
84
|
+
payload.setdefault("turn_id", turn_id)
|
|
85
|
+
payload.setdefault("created_at", now_iso())
|
|
86
|
+
payload["metadata_path"] = str(json_path)
|
|
87
|
+
payload["content_path"] = str(md_path)
|
|
88
|
+
payload["assistant_text_chars"] = len(assistant_text or "")
|
|
89
|
+
|
|
90
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
atomic_write(md_path, (assistant_text or "") + "\n")
|
|
92
|
+
atomic_write(json_path, json.dumps(payload, indent=2) + "\n")
|
|
93
|
+
|
|
94
|
+
return PmaTranscriptPointer(
|
|
95
|
+
turn_id=turn_id,
|
|
96
|
+
metadata_path=str(json_path),
|
|
97
|
+
content_path=str(md_path),
|
|
98
|
+
created_at=payload["created_at"],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def list_recent(self, *, limit: int = 50) -> list[dict[str, Any]]:
|
|
102
|
+
if limit <= 0:
|
|
103
|
+
return []
|
|
104
|
+
if not self._dir.exists():
|
|
105
|
+
return []
|
|
106
|
+
entries: list[dict[str, Any]] = []
|
|
107
|
+
for path in sorted(self._dir.glob("*.json"), reverse=True):
|
|
108
|
+
try:
|
|
109
|
+
raw = path.read_text(encoding="utf-8")
|
|
110
|
+
data = json.loads(raw)
|
|
111
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
112
|
+
logger.warning(
|
|
113
|
+
"Failed to read PMA transcript metadata at %s: %s", path, exc
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
if not isinstance(data, dict):
|
|
117
|
+
continue
|
|
118
|
+
content_path = Path(str(data.get("content_path") or ""))
|
|
119
|
+
if not content_path.is_absolute():
|
|
120
|
+
content_path = (path.parent / content_path).resolve()
|
|
121
|
+
data = dict(data)
|
|
122
|
+
data["preview"] = _read_preview(content_path)
|
|
123
|
+
entries.append(data)
|
|
124
|
+
if len(entries) >= limit:
|
|
125
|
+
break
|
|
126
|
+
return entries
|
|
127
|
+
|
|
128
|
+
def read_transcript(self, turn_id: str) -> Optional[dict[str, Any]]:
|
|
129
|
+
match = self._find_metadata(turn_id)
|
|
130
|
+
if not match:
|
|
131
|
+
return None
|
|
132
|
+
meta, meta_path = match
|
|
133
|
+
content_path = Path(str(meta.get("content_path") or ""))
|
|
134
|
+
if not content_path.is_absolute():
|
|
135
|
+
content_path = (meta_path.parent / content_path).resolve()
|
|
136
|
+
try:
|
|
137
|
+
content = content_path.read_text(encoding="utf-8")
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
logger.warning(
|
|
140
|
+
"Failed to read PMA transcript content at %s: %s", content_path, exc
|
|
141
|
+
)
|
|
142
|
+
content = ""
|
|
143
|
+
return {"metadata": meta, "content": content}
|
|
144
|
+
|
|
145
|
+
def _find_metadata(self, turn_id: str) -> Optional[tuple[dict[str, Any], Path]]:
|
|
146
|
+
if not self._dir.exists():
|
|
147
|
+
return None
|
|
148
|
+
safe_turn_id = _safe_segment(turn_id)
|
|
149
|
+
candidates = sorted(self._dir.glob(f"*_{safe_turn_id}.json"), reverse=True)
|
|
150
|
+
for path in candidates:
|
|
151
|
+
meta = self._read_metadata(path)
|
|
152
|
+
if meta and str(meta.get("turn_id")) == turn_id:
|
|
153
|
+
return meta, path
|
|
154
|
+
if candidates:
|
|
155
|
+
meta = self._read_metadata(candidates[0])
|
|
156
|
+
if meta:
|
|
157
|
+
return meta, candidates[0]
|
|
158
|
+
for path in sorted(self._dir.glob("*.json"), reverse=True):
|
|
159
|
+
meta = self._read_metadata(path)
|
|
160
|
+
if meta and str(meta.get("turn_id")) == turn_id:
|
|
161
|
+
return meta, path
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def _read_metadata(self, path: Path) -> Optional[dict[str, Any]]:
|
|
165
|
+
try:
|
|
166
|
+
raw = path.read_text(encoding="utf-8")
|
|
167
|
+
data = json.loads(raw)
|
|
168
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
169
|
+
logger.warning(
|
|
170
|
+
"Failed to read PMA transcript metadata at %s: %s", path, exc
|
|
171
|
+
)
|
|
172
|
+
return None
|
|
173
|
+
return data if isinstance(data, dict) else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = [
|
|
177
|
+
"PMA_TRANSCRIPTS_DIRNAME",
|
|
178
|
+
"PMA_TRANSCRIPT_PREVIEW_CHARS",
|
|
179
|
+
"PMA_TRANSCRIPT_VERSION",
|
|
180
|
+
"PmaTranscriptPointer",
|
|
181
|
+
"PmaTranscriptStore",
|
|
182
|
+
"default_pma_transcripts_dir",
|
|
183
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Safe path validation utilities for web endpoints.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for validating user-controlled paths to prevent
|
|
4
|
+
directory traversal attacks and other path-based security issues.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SafePathError(Exception):
|
|
12
|
+
"""Raised when a path fails safety validation."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, path: Optional[str] = None) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.path = path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_relative_posix_path(raw: str) -> PurePosixPath:
|
|
20
|
+
"""Validate a user-provided path string and return a PurePosixPath.
|
|
21
|
+
|
|
22
|
+
This function validates that:
|
|
23
|
+
1. The path is not absolute
|
|
24
|
+
2. The path does not contain '..' segments (parent directory traversal)
|
|
25
|
+
3. The path does not contain backslashes (Windows separators)
|
|
26
|
+
4. The path is not empty, '.', or only slashes
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
raw: The user-provided path string (typically from a URL path parameter)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A validated PurePosixPath object
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
SafePathError: If the path fails validation
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
>>> validate_relative_posix_path("file.txt")
|
|
39
|
+
PurePosixPath('file.txt')
|
|
40
|
+
|
|
41
|
+
>>> validate_relative_posix_path("a/b/c.txt")
|
|
42
|
+
PurePosixPath('a/b/c.txt')
|
|
43
|
+
|
|
44
|
+
>>> validate_relative_posix_path("../etc/passwd")
|
|
45
|
+
SafePathError: Invalid path: '..' not allowed
|
|
46
|
+
|
|
47
|
+
>>> validate_relative_posix_path("/etc/passwd")
|
|
48
|
+
SafePathError: Absolute paths not allowed
|
|
49
|
+
"""
|
|
50
|
+
if not raw or raw.strip() == "" or raw == ".":
|
|
51
|
+
raise SafePathError("Invalid path: empty or '.'", path=raw)
|
|
52
|
+
|
|
53
|
+
# Reject backslashes early (Windows separators)
|
|
54
|
+
if "\\" in raw:
|
|
55
|
+
raise SafePathError("Invalid path: backslashes not allowed", path=raw)
|
|
56
|
+
|
|
57
|
+
# Reject '..' in the raw path before PurePosixPath normalizes it
|
|
58
|
+
# We need to check the raw string because PurePosixPath("a/../b")
|
|
59
|
+
# normalizes to "b", which would bypass the later parts check
|
|
60
|
+
if ".." in raw:
|
|
61
|
+
raise SafePathError("Invalid path: '..' not allowed", path=raw)
|
|
62
|
+
|
|
63
|
+
# Parse with PurePosixPath to ensure POSIX semantics
|
|
64
|
+
try:
|
|
65
|
+
file_rel = PurePosixPath(raw)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
raise SafePathError(f"Invalid path: {exc}", path=raw) from exc
|
|
68
|
+
|
|
69
|
+
# Reject absolute paths
|
|
70
|
+
if file_rel.is_absolute():
|
|
71
|
+
raise SafePathError("Absolute paths not allowed", path=raw)
|
|
72
|
+
|
|
73
|
+
# Double-check '..' traversal segments after parsing (for edge cases)
|
|
74
|
+
if ".." in file_rel.parts:
|
|
75
|
+
raise SafePathError("Invalid path: '..' not allowed", path=raw)
|
|
76
|
+
|
|
77
|
+
return file_rel
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_single_filename(raw: str) -> str:
|
|
81
|
+
"""Validate that a path string represents only a single filename (no subpaths).
|
|
82
|
+
|
|
83
|
+
This is a stricter version of validate_relative_posix_path that only allows
|
|
84
|
+
a single filename component, not subdirectories.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
raw: The user-provided path string
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The validated filename
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
SafePathError: If the path contains slashes or is otherwise invalid
|
|
94
|
+
|
|
95
|
+
Examples:
|
|
96
|
+
>>> validate_single_filename("file.txt")
|
|
97
|
+
'file.txt'
|
|
98
|
+
|
|
99
|
+
>>> validate_single_filename("a/b.txt")
|
|
100
|
+
SafePathError: Subpaths not allowed: only single filenames permitted
|
|
101
|
+
|
|
102
|
+
>>> validate_single_filename("../etc/passwd")
|
|
103
|
+
SafePathError: Subpaths not allowed: only single filenames permitted
|
|
104
|
+
"""
|
|
105
|
+
file_rel = validate_relative_posix_path(raw)
|
|
106
|
+
|
|
107
|
+
# Ensure only a single component (no subpaths)
|
|
108
|
+
if len(file_rel.parts) != 1:
|
|
109
|
+
raise SafePathError(
|
|
110
|
+
"Subpaths not allowed: only single filenames permitted", path=raw
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Return the string representation of the filename
|
|
114
|
+
return str(file_rel)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["SafePathError", "validate_relative_posix_path", "validate_single_filename"]
|
codex_autorunner/housekeeping.py
CHANGED
|
@@ -8,6 +8,8 @@ from collections import deque
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any, Iterable, Optional, Protocol, cast
|
|
10
10
|
|
|
11
|
+
_MAX_ERROR_SAMPLES = 5
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
@dataclasses.dataclass(frozen=True)
|
|
13
15
|
class HousekeepingRule:
|
|
@@ -42,6 +44,7 @@ class HousekeepingRuleResult:
|
|
|
42
44
|
deleted_bytes: int = 0
|
|
43
45
|
truncated_bytes: int = 0
|
|
44
46
|
errors: int = 0
|
|
47
|
+
error_samples: list[str] = dataclasses.field(default_factory=list)
|
|
45
48
|
duration_ms: int = 0
|
|
46
49
|
|
|
47
50
|
|
|
@@ -136,21 +139,26 @@ def run_housekeeping_once(
|
|
|
136
139
|
continue
|
|
137
140
|
results.append(result)
|
|
138
141
|
if logger is not None:
|
|
142
|
+
log_fields: dict[str, Any] = {
|
|
143
|
+
"name": result.name,
|
|
144
|
+
"kind": result.kind,
|
|
145
|
+
"scanned_count": result.scanned_count,
|
|
146
|
+
"eligible_count": result.eligible_count,
|
|
147
|
+
"deleted_count": result.deleted_count,
|
|
148
|
+
"deleted_bytes": result.deleted_bytes,
|
|
149
|
+
"truncated_bytes": result.truncated_bytes,
|
|
150
|
+
"errors": result.errors,
|
|
151
|
+
"duration_ms": result.duration_ms,
|
|
152
|
+
"dry_run": config.dry_run,
|
|
153
|
+
"root": str(root),
|
|
154
|
+
}
|
|
155
|
+
if result.errors > 0 and result.error_samples:
|
|
156
|
+
log_fields["error_samples"] = result.error_samples
|
|
139
157
|
_log_event(
|
|
140
158
|
logger,
|
|
141
159
|
logging.INFO,
|
|
142
160
|
"housekeeping.rule",
|
|
143
|
-
|
|
144
|
-
kind=result.kind,
|
|
145
|
-
scanned_count=result.scanned_count,
|
|
146
|
-
eligible_count=result.eligible_count,
|
|
147
|
-
deleted_count=result.deleted_count,
|
|
148
|
-
deleted_bytes=result.deleted_bytes,
|
|
149
|
-
truncated_bytes=result.truncated_bytes,
|
|
150
|
-
errors=result.errors,
|
|
151
|
-
duration_ms=result.duration_ms,
|
|
152
|
-
dry_run=config.dry_run,
|
|
153
|
-
root=str(root),
|
|
161
|
+
**log_fields,
|
|
154
162
|
)
|
|
155
163
|
if logger is not None:
|
|
156
164
|
_log_event(
|
|
@@ -174,7 +182,7 @@ def _apply_directory_rule(
|
|
|
174
182
|
return result
|
|
175
183
|
now = time.time()
|
|
176
184
|
min_age = max(config.min_file_age_seconds, 0)
|
|
177
|
-
files = _collect_files(base, rule)
|
|
185
|
+
files = _collect_files(base, rule, result)
|
|
178
186
|
result.scanned_count = len(files)
|
|
179
187
|
if not files:
|
|
180
188
|
result.duration_ms = int((time.monotonic() - start) * 1000)
|
|
@@ -192,8 +200,9 @@ def _apply_directory_rule(
|
|
|
192
200
|
if not config.dry_run:
|
|
193
201
|
try:
|
|
194
202
|
entry.path.unlink()
|
|
195
|
-
except OSError:
|
|
203
|
+
except OSError as e:
|
|
196
204
|
errors += 1
|
|
205
|
+
_add_error_sample(result, "unlink", entry.path, e)
|
|
197
206
|
return
|
|
198
207
|
deleted.add(entry.path)
|
|
199
208
|
deleted_bytes += entry.size
|
|
@@ -254,8 +263,9 @@ def _apply_file_rule(
|
|
|
254
263
|
return result
|
|
255
264
|
try:
|
|
256
265
|
stat = path.stat()
|
|
257
|
-
except OSError:
|
|
266
|
+
except OSError as e:
|
|
258
267
|
result.errors = 1
|
|
268
|
+
_add_error_sample(result, "stat", path, e)
|
|
259
269
|
return result
|
|
260
270
|
if not path.is_file():
|
|
261
271
|
return result
|
|
@@ -271,6 +281,7 @@ def _apply_file_rule(
|
|
|
271
281
|
path,
|
|
272
282
|
rule.max_lines,
|
|
273
283
|
dry_run=config.dry_run,
|
|
284
|
+
result=result,
|
|
274
285
|
)
|
|
275
286
|
result.truncated_bytes += truncated
|
|
276
287
|
if rule.max_bytes is not None:
|
|
@@ -278,13 +289,16 @@ def _apply_file_rule(
|
|
|
278
289
|
path,
|
|
279
290
|
rule.max_bytes,
|
|
280
291
|
dry_run=config.dry_run,
|
|
292
|
+
result=result,
|
|
281
293
|
)
|
|
282
294
|
result.truncated_bytes += truncated
|
|
283
295
|
result.duration_ms = int((time.monotonic() - start) * 1000)
|
|
284
296
|
return result
|
|
285
297
|
|
|
286
298
|
|
|
287
|
-
def _collect_files(
|
|
299
|
+
def _collect_files(
|
|
300
|
+
base: Path, rule: HousekeepingRule, result: Optional[HousekeepingRuleResult] = None
|
|
301
|
+
) -> list[_FileInfo]:
|
|
288
302
|
results: list[_FileInfo] = []
|
|
289
303
|
glob_pattern = rule.glob or "*"
|
|
290
304
|
iterator = base.rglob(glob_pattern) if rule.recursive else base.glob(glob_pattern)
|
|
@@ -293,7 +307,9 @@ def _collect_files(base: Path, rule: HousekeepingRule) -> list[_FileInfo]:
|
|
|
293
307
|
if not path.is_file():
|
|
294
308
|
continue
|
|
295
309
|
stat = path.stat()
|
|
296
|
-
except OSError:
|
|
310
|
+
except OSError as e:
|
|
311
|
+
if result is not None:
|
|
312
|
+
_add_error_sample(result, "stat", path, e)
|
|
297
313
|
continue
|
|
298
314
|
results.append(_FileInfo(path=path, size=stat.st_size, mtime=stat.st_mtime))
|
|
299
315
|
return results
|
|
@@ -310,12 +326,21 @@ def _is_absolute_path(path: str) -> bool:
|
|
|
310
326
|
return Path(path).expanduser().is_absolute()
|
|
311
327
|
|
|
312
328
|
|
|
313
|
-
def _truncate_bytes(
|
|
329
|
+
def _truncate_bytes(
|
|
330
|
+
path: Path,
|
|
331
|
+
max_bytes: int,
|
|
332
|
+
*,
|
|
333
|
+
dry_run: bool,
|
|
334
|
+
result: Optional[HousekeepingRuleResult] = None,
|
|
335
|
+
) -> int:
|
|
314
336
|
if max_bytes <= 0:
|
|
315
337
|
return 0
|
|
316
338
|
try:
|
|
317
339
|
size = path.stat().st_size
|
|
318
|
-
except OSError:
|
|
340
|
+
except OSError as e:
|
|
341
|
+
if result is not None:
|
|
342
|
+
result.errors += 1
|
|
343
|
+
_add_error_sample(result, "truncate_bytes", path, e)
|
|
319
344
|
return 0
|
|
320
345
|
if size <= max_bytes:
|
|
321
346
|
return 0
|
|
@@ -328,23 +353,38 @@ def _truncate_bytes(path: Path, max_bytes: int, *, dry_run: bool) -> int:
|
|
|
328
353
|
payload = handle.read()
|
|
329
354
|
_atomic_write_bytes(path, payload)
|
|
330
355
|
return truncated
|
|
331
|
-
except OSError:
|
|
356
|
+
except OSError as e:
|
|
357
|
+
if result is not None:
|
|
358
|
+
result.errors += 1
|
|
359
|
+
_add_error_sample(result, "truncate_bytes", path, e)
|
|
332
360
|
return 0
|
|
333
361
|
|
|
334
362
|
|
|
335
|
-
def _truncate_lines(
|
|
363
|
+
def _truncate_lines(
|
|
364
|
+
path: Path,
|
|
365
|
+
max_lines: int,
|
|
366
|
+
*,
|
|
367
|
+
dry_run: bool,
|
|
368
|
+
result: Optional[HousekeepingRuleResult] = None,
|
|
369
|
+
) -> int:
|
|
336
370
|
if max_lines <= 0:
|
|
337
371
|
return 0
|
|
338
372
|
try:
|
|
339
373
|
size = path.stat().st_size
|
|
340
|
-
except OSError:
|
|
374
|
+
except OSError as e:
|
|
375
|
+
if result is not None:
|
|
376
|
+
result.errors += 1
|
|
377
|
+
_add_error_sample(result, "truncate_lines", path, e)
|
|
341
378
|
return 0
|
|
342
379
|
lines: deque[bytes] = deque(maxlen=max_lines)
|
|
343
380
|
try:
|
|
344
381
|
with path.open("rb") as handle:
|
|
345
382
|
for line in handle:
|
|
346
383
|
lines.append(line)
|
|
347
|
-
except OSError:
|
|
384
|
+
except OSError as e:
|
|
385
|
+
if result is not None:
|
|
386
|
+
result.errors += 1
|
|
387
|
+
_add_error_sample(result, "truncate_lines", path, e)
|
|
348
388
|
return 0
|
|
349
389
|
payload = b"".join(lines)
|
|
350
390
|
if len(payload) >= size:
|
|
@@ -353,7 +393,10 @@ def _truncate_lines(path: Path, max_lines: int, *, dry_run: bool) -> int:
|
|
|
353
393
|
return size - len(payload)
|
|
354
394
|
try:
|
|
355
395
|
_atomic_write_bytes(path, payload)
|
|
356
|
-
except OSError:
|
|
396
|
+
except OSError as e:
|
|
397
|
+
if result is not None:
|
|
398
|
+
result.errors += 1
|
|
399
|
+
_add_error_sample(result, "truncate_lines", path, e)
|
|
357
400
|
return 0
|
|
358
401
|
return size - len(payload)
|
|
359
402
|
|
|
@@ -379,6 +422,17 @@ def _prune_empty_dirs(base: Path) -> None:
|
|
|
379
422
|
continue
|
|
380
423
|
|
|
381
424
|
|
|
425
|
+
def _add_error_sample(
|
|
426
|
+
result: HousekeepingRuleResult, operation: str, path: Path, exc: OSError
|
|
427
|
+
) -> None:
|
|
428
|
+
if len(result.error_samples) >= _MAX_ERROR_SAMPLES:
|
|
429
|
+
return
|
|
430
|
+
exc_info = f"{type(exc).__name__}"
|
|
431
|
+
if exc.strerror:
|
|
432
|
+
exc_info += f": {exc.strerror}"
|
|
433
|
+
result.error_samples.append(f"{operation} {path}: {exc_info}")
|
|
434
|
+
|
|
435
|
+
|
|
382
436
|
def _int_or_none(value: object) -> Optional[int]:
|
|
383
437
|
if value is None:
|
|
384
438
|
return None
|
|
@@ -47,6 +47,7 @@ class CodexAppServerBackend(AgentBackend):
|
|
|
47
47
|
restart_backoff_initial_seconds: Optional[float] = None,
|
|
48
48
|
restart_backoff_max_seconds: Optional[float] = None,
|
|
49
49
|
restart_backoff_jitter_ratio: Optional[float] = None,
|
|
50
|
+
output_policy: str = "final_only",
|
|
50
51
|
notification_handler: Optional[NotificationHandler] = None,
|
|
51
52
|
logger: Optional[logging.Logger] = None,
|
|
52
53
|
):
|
|
@@ -71,6 +72,7 @@ class CodexAppServerBackend(AgentBackend):
|
|
|
71
72
|
self._restart_backoff_initial_seconds = restart_backoff_initial_seconds
|
|
72
73
|
self._restart_backoff_max_seconds = restart_backoff_max_seconds
|
|
73
74
|
self._restart_backoff_jitter_ratio = restart_backoff_jitter_ratio
|
|
75
|
+
self._output_policy = output_policy
|
|
74
76
|
self._notification_handler = notification_handler
|
|
75
77
|
self._logger = logger or _logger
|
|
76
78
|
|
|
@@ -102,6 +104,7 @@ class CodexAppServerBackend(AgentBackend):
|
|
|
102
104
|
restart_backoff_initial_seconds=self._restart_backoff_initial_seconds,
|
|
103
105
|
restart_backoff_max_seconds=self._restart_backoff_max_seconds,
|
|
104
106
|
restart_backoff_jitter_ratio=self._restart_backoff_jitter_ratio,
|
|
107
|
+
output_policy=self._output_policy,
|
|
105
108
|
logger=self._logger,
|
|
106
109
|
)
|
|
107
110
|
await self._client.start()
|
|
@@ -201,16 +204,18 @@ class CodexAppServerBackend(AgentBackend):
|
|
|
201
204
|
yield AgentEvent.stream_delta(content=message, delta_type="user_message")
|
|
202
205
|
|
|
203
206
|
result = await handle.wait(timeout=self._turn_timeout_seconds)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
+
final_text = str(getattr(result, "final_message", "") or "")
|
|
208
|
+
if not final_text.strip():
|
|
209
|
+
final_text = "\n\n".join(
|
|
210
|
+
msg.strip()
|
|
211
|
+
for msg in getattr(result, "agent_messages", [])
|
|
212
|
+
if isinstance(msg, str) and msg.strip()
|
|
213
|
+
)
|
|
207
214
|
|
|
208
215
|
for event_data in result.raw_events:
|
|
209
216
|
yield self._parse_raw_event(event_data)
|
|
210
217
|
|
|
211
|
-
yield AgentEvent.message_complete(
|
|
212
|
-
final_message="\n".join(result.agent_messages)
|
|
213
|
-
)
|
|
218
|
+
yield AgentEvent.message_complete(final_message=final_text)
|
|
214
219
|
|
|
215
220
|
async def run_turn_events(
|
|
216
221
|
self, session_id: str, message: str
|
|
@@ -283,11 +288,12 @@ class CodexAppServerBackend(AgentBackend):
|
|
|
283
288
|
if get_task in pending_set:
|
|
284
289
|
get_task.cancel()
|
|
285
290
|
result = wait_task.result()
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
+
final_text = str(getattr(result, "final_message", "") or "")
|
|
292
|
+
if not final_text.strip():
|
|
293
|
+
final_text = "\n\n".join(
|
|
294
|
+
msg.strip()
|
|
295
|
+
for msg in getattr(result, "agent_messages", [])
|
|
296
|
+
if isinstance(msg, str) and msg.strip()
|
|
291
297
|
)
|
|
292
298
|
# raw_events already contain the same notifications we streamed
|
|
293
299
|
# through _event_queue; skipping here avoids double-emitting.
|
|
@@ -297,7 +303,7 @@ class CodexAppServerBackend(AgentBackend):
|
|
|
297
303
|
yield extra
|
|
298
304
|
yield Completed(
|
|
299
305
|
timestamp=now_iso(),
|
|
300
|
-
final_message=
|
|
306
|
+
final_message=final_text,
|
|
301
307
|
)
|
|
302
308
|
break
|
|
303
309
|
|
|
@@ -106,6 +106,7 @@ class AgentBackendFactory:
|
|
|
106
106
|
restart_backoff_initial_seconds=self._config.app_server.client.restart_backoff_initial_seconds,
|
|
107
107
|
restart_backoff_max_seconds=self._config.app_server.client.restart_backoff_max_seconds,
|
|
108
108
|
restart_backoff_jitter_ratio=self._config.app_server.client.restart_backoff_jitter_ratio,
|
|
109
|
+
output_policy=self._config.app_server.output.policy,
|
|
109
110
|
notification_handler=notification_handler,
|
|
110
111
|
logger=self._logger,
|
|
111
112
|
)
|
|
@@ -267,6 +268,7 @@ def build_app_server_supervisor_factory(
|
|
|
267
268
|
restart_backoff_initial_seconds=config.app_server.client.restart_backoff_initial_seconds,
|
|
268
269
|
restart_backoff_max_seconds=config.app_server.client.restart_backoff_max_seconds,
|
|
269
270
|
restart_backoff_jitter_ratio=config.app_server.client.restart_backoff_jitter_ratio,
|
|
271
|
+
output_policy=config.app_server.output.policy,
|
|
270
272
|
)
|
|
271
273
|
|
|
272
274
|
return factory
|
|
@@ -62,6 +62,8 @@ _TURN_STALL_POLL_INTERVAL_SECONDS = 2.0
|
|
|
62
62
|
_TURN_STALL_RECOVERY_MIN_INTERVAL_SECONDS = 10.0
|
|
63
63
|
_MAX_TURN_RAW_EVENTS = 200
|
|
64
64
|
_INVALID_JSON_PREVIEW_BYTES = 200
|
|
65
|
+
_DEFAULT_OUTPUT_POLICY = "final_only"
|
|
66
|
+
_OUTPUT_POLICIES = {"final_only", "all_agent_messages"}
|
|
65
67
|
|
|
66
68
|
# Track live clients so tests/cleanup can cancel any background restart tasks.
|
|
67
69
|
_CLIENT_INSTANCES: weakref.WeakSet = weakref.WeakSet()
|
|
@@ -108,6 +110,7 @@ class CodexAppServerProtocolError(CodexAppServerError, PermanentError):
|
|
|
108
110
|
class TurnResult:
|
|
109
111
|
turn_id: str
|
|
110
112
|
status: Optional[str]
|
|
113
|
+
final_message: str
|
|
111
114
|
agent_messages: list[str]
|
|
112
115
|
errors: list[str]
|
|
113
116
|
raw_events: list[Dict[str, Any]]
|
|
@@ -163,6 +166,7 @@ class CodexAppServerClient:
|
|
|
163
166
|
restart_backoff_initial_seconds: Optional[float] = None,
|
|
164
167
|
restart_backoff_max_seconds: Optional[float] = None,
|
|
165
168
|
restart_backoff_jitter_ratio: Optional[float] = None,
|
|
169
|
+
output_policy: str = _DEFAULT_OUTPUT_POLICY,
|
|
166
170
|
notification_handler: Optional[NotificationHandler] = None,
|
|
167
171
|
logger: Optional[logging.Logger] = None,
|
|
168
172
|
) -> None:
|
|
@@ -217,6 +221,7 @@ class CodexAppServerClient:
|
|
|
217
221
|
and restart_backoff_jitter_ratio >= 0
|
|
218
222
|
else _RESTART_BACKOFF_JITTER_RATIO
|
|
219
223
|
)
|
|
224
|
+
self._output_policy = _normalize_output_policy(output_policy)
|
|
220
225
|
|
|
221
226
|
self._process: Optional[asyncio.subprocess.Process] = None
|
|
222
227
|
self._reader_task: Optional[asyncio.Task] = None
|
|
@@ -558,6 +563,9 @@ class CodexAppServerClient:
|
|
|
558
563
|
state.future.set_result(
|
|
559
564
|
TurnResult(
|
|
560
565
|
turn_id=state.turn_id,
|
|
566
|
+
final_message=_final_message_for_result(
|
|
567
|
+
state, policy=self._output_policy
|
|
568
|
+
),
|
|
561
569
|
agent_messages=_agent_messages_for_result(state),
|
|
562
570
|
errors=list(state.errors),
|
|
563
571
|
raw_events=list(state.raw_events),
|
|
@@ -1269,6 +1277,9 @@ class CodexAppServerClient:
|
|
|
1269
1277
|
TurnResult(
|
|
1270
1278
|
turn_id=target.turn_id,
|
|
1271
1279
|
status=target.status,
|
|
1280
|
+
final_message=_final_message_for_result(
|
|
1281
|
+
target, policy=self._output_policy
|
|
1282
|
+
),
|
|
1272
1283
|
agent_messages=_agent_messages_for_result(target),
|
|
1273
1284
|
errors=list(target.errors),
|
|
1274
1285
|
raw_events=list(target.raw_events),
|
|
@@ -1366,6 +1377,9 @@ class CodexAppServerClient:
|
|
|
1366
1377
|
TurnResult(
|
|
1367
1378
|
turn_id=state.turn_id,
|
|
1368
1379
|
status=state.status,
|
|
1380
|
+
final_message=_final_message_for_result(
|
|
1381
|
+
state, policy=self._output_policy
|
|
1382
|
+
),
|
|
1369
1383
|
agent_messages=_agent_messages_for_result(state),
|
|
1370
1384
|
errors=list(state.errors),
|
|
1371
1385
|
raw_events=list(state.raw_events),
|
|
@@ -1701,6 +1715,23 @@ def _agent_messages_for_result(state: _TurnState) -> list[str]:
|
|
|
1701
1715
|
return _agent_message_deltas_as_list(state.agent_message_deltas)
|
|
1702
1716
|
|
|
1703
1717
|
|
|
1718
|
+
def _normalize_output_policy(policy: Optional[str]) -> str:
|
|
1719
|
+
candidate = str(policy or "").strip().lower()
|
|
1720
|
+
if candidate in _OUTPUT_POLICIES:
|
|
1721
|
+
return candidate
|
|
1722
|
+
return _DEFAULT_OUTPUT_POLICY
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def _final_message_for_result(state: _TurnState, *, policy: str) -> str:
|
|
1726
|
+
messages = _agent_messages_for_result(state)
|
|
1727
|
+
cleaned = [msg.strip() for msg in messages if isinstance(msg, str) and msg.strip()]
|
|
1728
|
+
if not cleaned:
|
|
1729
|
+
return ""
|
|
1730
|
+
if policy == "all_agent_messages":
|
|
1731
|
+
return "\n\n".join(cleaned)
|
|
1732
|
+
return cleaned[-1]
|
|
1733
|
+
|
|
1734
|
+
|
|
1704
1735
|
def _extract_status_value(value: Any) -> Optional[str]:
|
|
1705
1736
|
if isinstance(value, str):
|
|
1706
1737
|
return value
|