pcode 0.1.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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/sessions.py
ADDED
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
"""Private session metadata/journal around Harness's native step persistence."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import sqlite3
|
|
8
|
+
import tempfile
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from filelock import FileLock, Timeout
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
from pydantic_ai_harness.step_persistence import SqliteStepStore, StepEvent, ToolEffectRecord
|
|
19
|
+
|
|
20
|
+
from pcode.conversation_tree import ConversationTree
|
|
21
|
+
from pcode.diagnostics import error_report, redact, versions
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SessionError(ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SessionBusy(SessionError):
|
|
29
|
+
"""Another process holds the session's lock."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionInfo(BaseModel):
|
|
33
|
+
version: Literal[1] = 1
|
|
34
|
+
id: str
|
|
35
|
+
model: str
|
|
36
|
+
workspace: str
|
|
37
|
+
# Preserve project scope even after a linked worktree has been removed.
|
|
38
|
+
project: str | None = None
|
|
39
|
+
# HEAD when the session began, so /diffs still shows work once it is committed.
|
|
40
|
+
start_commit: str | None = None
|
|
41
|
+
created: str
|
|
42
|
+
updated: str
|
|
43
|
+
status: str = "new"
|
|
44
|
+
# Set with /rename; /resume lists and searches it.
|
|
45
|
+
name: str | None = None
|
|
46
|
+
# Asked of the model beside the first turn (`session_naming`); `name` wins.
|
|
47
|
+
title: str | None = None
|
|
48
|
+
turns: int = 0
|
|
49
|
+
input_tokens: int = 0
|
|
50
|
+
output_tokens: int = 0
|
|
51
|
+
# Defaulted, so a session written before cache accounting still loads.
|
|
52
|
+
cache_read_tokens: int = 0
|
|
53
|
+
cache_write_tokens: int = 0
|
|
54
|
+
packages: dict[str, str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def now() -> str:
|
|
58
|
+
return datetime.now(timezone.utc).isoformat()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def session_root() -> Path:
|
|
62
|
+
if value := os.environ.get("PCODE_SESSION_DIR"):
|
|
63
|
+
return Path(value).expanduser()
|
|
64
|
+
state = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
|
|
65
|
+
return state / "pcode" / "sessions"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def private_file(path: Path) -> None:
|
|
69
|
+
fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
70
|
+
try:
|
|
71
|
+
os.fchmod(fd, 0o600)
|
|
72
|
+
finally:
|
|
73
|
+
os.close(fd)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_info(path: Path) -> SessionInfo:
|
|
77
|
+
try:
|
|
78
|
+
info = SessionInfo.model_validate_json((path / "session.json").read_text())
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
raise SessionError(
|
|
81
|
+
"Session metadata is unreadable or uses an unsupported format."
|
|
82
|
+
) from None
|
|
83
|
+
if info.id != path.name:
|
|
84
|
+
raise SessionError("Session ID does not match its directory.")
|
|
85
|
+
return info
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def list_sessions(root: Path | None = None) -> list[SessionInfo]:
|
|
89
|
+
root = root or session_root()
|
|
90
|
+
result = []
|
|
91
|
+
if root.is_dir():
|
|
92
|
+
for path in root.iterdir():
|
|
93
|
+
if path.is_dir() and not path.is_symlink():
|
|
94
|
+
try:
|
|
95
|
+
result.append(read_info(path))
|
|
96
|
+
except SessionError:
|
|
97
|
+
continue
|
|
98
|
+
return sorted(result, key=lambda info: info.updated, reverse=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_open(directory: Path) -> bool:
|
|
102
|
+
"""Whether a process holds this session, probed without keeping the lock."""
|
|
103
|
+
lock = FileLock(directory / ".lock", mode=0o600)
|
|
104
|
+
try:
|
|
105
|
+
lock.acquire(timeout=0)
|
|
106
|
+
except Timeout:
|
|
107
|
+
return True
|
|
108
|
+
except OSError:
|
|
109
|
+
return False # Deleted since it was listed; nothing can hold it now.
|
|
110
|
+
lock.release()
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def sharing(
|
|
115
|
+
workspace: Path, root: Path | None = None, exclude: str | None = None
|
|
116
|
+
) -> list[tuple[str, bool]]:
|
|
117
|
+
"""Other sessions still using `workspace`, as (ID, open) pairs, excluding `exclude`.
|
|
118
|
+
|
|
119
|
+
A copied session (`--fork`, or one whose original was busy) shares that
|
|
120
|
+
original's directory, so tidying a worktree on the way out has to ask this
|
|
121
|
+
first. One counts while it is open or has a finished turn to resume.
|
|
122
|
+
"""
|
|
123
|
+
root = root or session_root()
|
|
124
|
+
home = str(workspace.resolve())
|
|
125
|
+
found = []
|
|
126
|
+
for info in list_sessions(root):
|
|
127
|
+
if info.workspace != home or info.id == exclude:
|
|
128
|
+
continue
|
|
129
|
+
running = is_open(root / info.id)
|
|
130
|
+
if running or info.turns:
|
|
131
|
+
found.append((info.id, running))
|
|
132
|
+
return found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def delete_session(identity: str, root: Path | None = None) -> None:
|
|
136
|
+
"""Remove a saved session's directory; refuses one open in any process."""
|
|
137
|
+
root = root or session_root()
|
|
138
|
+
directory = root / identity
|
|
139
|
+
if directory.is_symlink() or not directory.is_dir() or directory.parent != root:
|
|
140
|
+
raise SessionError("No such session.")
|
|
141
|
+
lock = FileLock(directory / ".lock", mode=0o600)
|
|
142
|
+
try:
|
|
143
|
+
lock.acquire(timeout=0)
|
|
144
|
+
except Timeout:
|
|
145
|
+
raise SessionError("This session is open in another process.") from None
|
|
146
|
+
try:
|
|
147
|
+
try:
|
|
148
|
+
info = read_info(directory)
|
|
149
|
+
except SessionError:
|
|
150
|
+
info = None
|
|
151
|
+
shutil.rmtree(directory)
|
|
152
|
+
finally:
|
|
153
|
+
lock.release()
|
|
154
|
+
if info is not None:
|
|
155
|
+
# By the directory just removed: its refs are keyed by that id.
|
|
156
|
+
forget_refs(info.model_copy(update={"id": identity}))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def forget_refs(info: SessionInfo) -> None:
|
|
160
|
+
"""Drop the git refs a session kept (its /diffs review checkpoint).
|
|
161
|
+
|
|
162
|
+
Best effort: the repository may be gone, and a leftover ref only holds
|
|
163
|
+
objects git would otherwise collect.
|
|
164
|
+
"""
|
|
165
|
+
from pcode.git_diff import GitDiffError, forget_session
|
|
166
|
+
from pcode.worktree import session_scope
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
scope = session_scope(info)
|
|
170
|
+
if scope.is_dir():
|
|
171
|
+
forget_session(scope, info.id)
|
|
172
|
+
except (GitDiffError, OSError):
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class ToolCall:
|
|
178
|
+
"""One settled tool call, summarized the way scrollback summarized it.
|
|
179
|
+
|
|
180
|
+
The transcript's own `result` is deliberately left behind: it is most of a
|
|
181
|
+
transcript's bulk (hundreds of KB where the summaries are tens), and the
|
|
182
|
+
tool inspector is where a full result belongs.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
name: str
|
|
186
|
+
detail: str = ""
|
|
187
|
+
failed: bool = False
|
|
188
|
+
elapsed_seconds: float | None = None
|
|
189
|
+
command: str = ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class Turn:
|
|
194
|
+
"""One prompt and the final response it produced, for browsing and search."""
|
|
195
|
+
|
|
196
|
+
prompt: str
|
|
197
|
+
response: str = ""
|
|
198
|
+
status: str = "running" # "complete", "cancelled", "failed", or still "running".
|
|
199
|
+
# Text blocks and settled tool calls in transcript order, so a browsed turn
|
|
200
|
+
# can be replayed in the order it happened. `response` stays the last text
|
|
201
|
+
# block: the session list and the conversation tree want that one alone.
|
|
202
|
+
blocks: list["str | ToolCall"] = field(default_factory=list)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_TURN_KINDS = (
|
|
206
|
+
"turn_started",
|
|
207
|
+
"Message",
|
|
208
|
+
"ToolSummary",
|
|
209
|
+
"turn_completed",
|
|
210
|
+
"turn_cancelled",
|
|
211
|
+
"turn_failed",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class SessionReadBudget:
|
|
217
|
+
"""A shared byte budget for readers that can report partial coverage."""
|
|
218
|
+
|
|
219
|
+
remaining: int
|
|
220
|
+
exhausted: bool = False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def session_records(
|
|
224
|
+
info: SessionInfo,
|
|
225
|
+
root: Path | None = None,
|
|
226
|
+
*,
|
|
227
|
+
kinds: tuple[str, ...] = _TURN_KINDS,
|
|
228
|
+
budget: SessionReadBudget | None = None,
|
|
229
|
+
):
|
|
230
|
+
"""Yield turn-level records without opening or locking the session.
|
|
231
|
+
|
|
232
|
+
Raises ``OSError`` when the transcript cannot be read. Transcripts are
|
|
233
|
+
mostly streaming deltas, so only candidate lines are parsed.
|
|
234
|
+
"""
|
|
235
|
+
directory = (root or session_root()) / info.id
|
|
236
|
+
path = directory / "transcript.jsonl"
|
|
237
|
+
if directory.is_symlink() or path.is_symlink():
|
|
238
|
+
raise OSError("symlinked session")
|
|
239
|
+
markers = tuple(f'"{kind}"'.encode() for kind in kinds)
|
|
240
|
+
with path.open("rb") as stream:
|
|
241
|
+
while line := stream.readline(budget.remaining + 1 if budget else -1):
|
|
242
|
+
if budget:
|
|
243
|
+
if len(line) > budget.remaining:
|
|
244
|
+
budget.exhausted = True
|
|
245
|
+
return
|
|
246
|
+
budget.remaining -= len(line)
|
|
247
|
+
if not any(marker in line for marker in markers):
|
|
248
|
+
continue
|
|
249
|
+
try:
|
|
250
|
+
record = json.loads(line.decode("utf-8", errors="replace"))
|
|
251
|
+
except ValueError:
|
|
252
|
+
continue
|
|
253
|
+
if isinstance(record, dict) and record.get("kind") in kinds:
|
|
254
|
+
yield record
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def session_turns(info: SessionInfo, root: Path | None = None) -> list[Turn] | None:
|
|
258
|
+
"""Read every turn in file order; ``None`` when the transcript is unreadable.
|
|
259
|
+
|
|
260
|
+
Forked branches are included: reconstructing the active path needs the
|
|
261
|
+
session lock, which the live session holds, and abandoned prompts are
|
|
262
|
+
still worth finding.
|
|
263
|
+
"""
|
|
264
|
+
turns: list[Turn] = []
|
|
265
|
+
try:
|
|
266
|
+
for record in session_records(info, root):
|
|
267
|
+
kind = record["kind"]
|
|
268
|
+
if kind == "turn_started":
|
|
269
|
+
prompt = record.get("prompt")
|
|
270
|
+
if not isinstance(prompt, str):
|
|
271
|
+
continue
|
|
272
|
+
# A /resend retry reuses the prompt; its outcome belongs to the same turn.
|
|
273
|
+
if record.get("continuation") and turns and turns[-1].prompt == prompt:
|
|
274
|
+
turns[-1].status = "running"
|
|
275
|
+
continue
|
|
276
|
+
turns.append(Turn(prompt))
|
|
277
|
+
elif not turns:
|
|
278
|
+
continue
|
|
279
|
+
elif kind == "Message":
|
|
280
|
+
markdown = record.get("markdown")
|
|
281
|
+
if isinstance(markdown, str):
|
|
282
|
+
turns[-1].response = markdown
|
|
283
|
+
turns[-1].blocks.append(markdown)
|
|
284
|
+
elif kind == "ToolSummary":
|
|
285
|
+
elapsed = record.get("elapsed_seconds")
|
|
286
|
+
turns[-1].blocks.append(
|
|
287
|
+
ToolCall(
|
|
288
|
+
str(record.get("name") or ""),
|
|
289
|
+
str(record.get("detail") or ""),
|
|
290
|
+
bool(record.get("failed")),
|
|
291
|
+
elapsed if isinstance(elapsed, (int, float)) else None,
|
|
292
|
+
str(record.get("command") or ""),
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
elif kind == "turn_completed":
|
|
296
|
+
turns[-1].status = "complete"
|
|
297
|
+
elif kind == "turn_cancelled":
|
|
298
|
+
turns[-1].status = "cancelled"
|
|
299
|
+
elif kind == "turn_failed":
|
|
300
|
+
turns[-1].status = "failed"
|
|
301
|
+
except OSError:
|
|
302
|
+
return None
|
|
303
|
+
return turns
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def first_prompt(info: SessionInfo, root: Path | None = None) -> str:
|
|
307
|
+
"""The first submitted prompt, stopping at the first record so listing stays cheap."""
|
|
308
|
+
try:
|
|
309
|
+
for record in session_records(info, root):
|
|
310
|
+
if record["kind"] == "turn_started" and isinstance(record.get("prompt"), str):
|
|
311
|
+
return record["prompt"]
|
|
312
|
+
except OSError:
|
|
313
|
+
return "(Prompt unavailable)"
|
|
314
|
+
return "(No prompt yet)"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
SNAPSHOTS_PER_RUN = 2
|
|
318
|
+
"""How many step checkpoints a turn keeps.
|
|
319
|
+
|
|
320
|
+
Harness saves the whole message history again at every settled step, so a turn
|
|
321
|
+
with 300 tool calls stores 300 copies of itself and a session reaches gigabytes.
|
|
322
|
+
Nothing here reads them: `history_at` and `recover` both take the newest
|
|
323
|
+
snapshot of a run, and a compaction node restores from the conversation tree
|
|
324
|
+
instead. The bound keeps the newest two, and Harness widens that retain set to
|
|
325
|
+
cover the newest `complete` one when the newest is interrupted, so a crash mid
|
|
326
|
+
turn still continues from its last settled step.
|
|
327
|
+
|
|
328
|
+
What it gives up is rewinding *inside* a turn, which no command offers, and
|
|
329
|
+
pre-compaction history for a run, which `history_at` takes from the tree.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _store_bytes(database: Path) -> int:
|
|
334
|
+
"""The store's size including its write-ahead log, where recent writes still live."""
|
|
335
|
+
names = (database.name, database.name + "-wal", database.name + "-shm")
|
|
336
|
+
return sum(
|
|
337
|
+
(database.parent / name).stat().st_size
|
|
338
|
+
for name in names
|
|
339
|
+
if (database.parent / name).is_file()
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def compact_snapshots(directory: Path, keep: int = SNAPSHOTS_PER_RUN) -> tuple[int, int]:
|
|
344
|
+
"""Apply the retain bound to one existing session, returning its size before and after.
|
|
345
|
+
|
|
346
|
+
Sessions written before the bound existed keep every step checkpoint, and
|
|
347
|
+
deleting rows alone frees pages for reuse without returning them to the
|
|
348
|
+
filesystem, so this vacuums as well. Both steps are fast because the live
|
|
349
|
+
data is what remains: about a second for the largest session observed.
|
|
350
|
+
|
|
351
|
+
A session open elsewhere is skipped (its size reported unchanged) rather
|
|
352
|
+
than compacted underneath the process still writing to it.
|
|
353
|
+
"""
|
|
354
|
+
database = directory / "steps.sqlite3"
|
|
355
|
+
if not database.is_file():
|
|
356
|
+
return 0, 0
|
|
357
|
+
before = _store_bytes(database)
|
|
358
|
+
lock = FileLock(directory / ".lock", mode=0o600)
|
|
359
|
+
try:
|
|
360
|
+
lock.acquire(timeout=0)
|
|
361
|
+
except Timeout:
|
|
362
|
+
return before, before
|
|
363
|
+
try:
|
|
364
|
+
connection = sqlite3.connect(database)
|
|
365
|
+
try:
|
|
366
|
+
# Mirrors Harness's retain set: the newest `keep` of each run, plus
|
|
367
|
+
# each run's newest settled snapshot, which is the one every read
|
|
368
|
+
# here takes. A row predating the state column counts as complete.
|
|
369
|
+
connection.execute(
|
|
370
|
+
"DELETE FROM snapshots WHERE seq NOT IN ("
|
|
371
|
+
" SELECT seq FROM ("
|
|
372
|
+
" SELECT seq, row_number() OVER ("
|
|
373
|
+
" PARTITION BY run_id ORDER BY seq DESC) AS rank FROM snapshots"
|
|
374
|
+
" ) WHERE rank <= ?"
|
|
375
|
+
" UNION SELECT max(seq) FROM snapshots"
|
|
376
|
+
" WHERE coalesce(state, 'complete') = 'complete' GROUP BY run_id)",
|
|
377
|
+
(keep,),
|
|
378
|
+
)
|
|
379
|
+
connection.commit()
|
|
380
|
+
connection.execute("VACUUM")
|
|
381
|
+
connection.commit()
|
|
382
|
+
# A vacuum in WAL mode rewrites the store into the log; the file on
|
|
383
|
+
# disk only shrinks once that log is folded back and truncated.
|
|
384
|
+
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
385
|
+
finally:
|
|
386
|
+
connection.close()
|
|
387
|
+
except sqlite3.Error:
|
|
388
|
+
# A session whose store cannot be rewritten is left exactly as it was.
|
|
389
|
+
return before, _store_bytes(database)
|
|
390
|
+
finally:
|
|
391
|
+
lock.release()
|
|
392
|
+
return before, _store_bytes(database)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def is_session_selector(value: str) -> bool:
|
|
396
|
+
"""Tell an ID/prefix apart from prompt text after an optional-argument flag."""
|
|
397
|
+
return value == "latest" or bool(re.fullmatch(r"[a-f0-9-]{8,36}", value))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def resolve_session(selector: str, root: Path | None = None, workspace: Path | None = None) -> Path:
|
|
401
|
+
"""Resolve an ID/prefix, or the newest session, optionally scoped to a workspace."""
|
|
402
|
+
root = root or session_root()
|
|
403
|
+
if not is_session_selector(selector):
|
|
404
|
+
raise SessionError("Use a session ID from --sessions, or omit it for the latest.")
|
|
405
|
+
sessions = list_sessions(root)
|
|
406
|
+
if selector != "latest":
|
|
407
|
+
matches = [s for s in sessions if s.id.startswith(selector)]
|
|
408
|
+
elif workspace is None:
|
|
409
|
+
matches = sessions[:1]
|
|
410
|
+
else:
|
|
411
|
+
# "Latest" means this checkout's newest session, never another repo's.
|
|
412
|
+
home = str(workspace.resolve())
|
|
413
|
+
matches = [s for s in sessions if s.workspace == home][:1]
|
|
414
|
+
if not matches:
|
|
415
|
+
raise SessionError(f"No saved session for {home}; use --sessions to pick one.")
|
|
416
|
+
if len(matches) != 1:
|
|
417
|
+
raise SessionError("Session not found or prefix is ambiguous; use --sessions.")
|
|
418
|
+
return root / matches[0].id
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class PrivateStepStore(SqliteStepStore):
|
|
422
|
+
"""Use the public store hooks to redact error strings, not replay-critical history."""
|
|
423
|
+
|
|
424
|
+
async def append_event(self, event: StepEvent) -> None:
|
|
425
|
+
if event.error:
|
|
426
|
+
event = replace(event, error=redact(event.error))
|
|
427
|
+
await super().append_event(event)
|
|
428
|
+
|
|
429
|
+
async def record_tool_effect(self, record: ToolEffectRecord) -> None:
|
|
430
|
+
if record.effect_summary:
|
|
431
|
+
record = replace(record, effect_summary=redact(record.effect_summary))
|
|
432
|
+
await super().record_tool_effect(record)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _parse(line: bytes) -> dict | None:
|
|
436
|
+
"""One journal line as a record; anything else (torn, foreign) is skipped."""
|
|
437
|
+
try:
|
|
438
|
+
record = json.loads(line.decode("utf-8", errors="replace"))
|
|
439
|
+
except ValueError:
|
|
440
|
+
return None
|
|
441
|
+
return record if isinstance(record, dict) else None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class SessionJournal:
|
|
445
|
+
"""A session's conversation, readable without owning it.
|
|
446
|
+
|
|
447
|
+
`read` never locks or writes, so a terminal can display a session that a
|
|
448
|
+
host process holds open and keeps appending to; `refresh` follows it.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
def __init__(self, directory: Path, info: SessionInfo) -> None:
|
|
452
|
+
if directory.is_symlink():
|
|
453
|
+
raise SessionError("Refusing a symlinked session directory.")
|
|
454
|
+
self.directory = directory
|
|
455
|
+
self.info = info
|
|
456
|
+
# The session this one was copied from because that one was open.
|
|
457
|
+
self.forked_from: str | None = None
|
|
458
|
+
# True when the copy was asked for (`--fork`) rather than forced by a lock.
|
|
459
|
+
self.fork_requested = False
|
|
460
|
+
self.tree = ConversationTree()
|
|
461
|
+
# End of the last complete journal line fed to `tree`, and which file it
|
|
462
|
+
# was in, so a replaced journal is rebuilt rather than read mid-record.
|
|
463
|
+
self._offset = 0
|
|
464
|
+
self._journal_id: tuple[int, int] | None = None
|
|
465
|
+
|
|
466
|
+
@classmethod
|
|
467
|
+
def read(cls, directory: Path) -> "SessionJournal":
|
|
468
|
+
"""Open a session read-only, even while another process holds and writes it."""
|
|
469
|
+
if directory.is_symlink():
|
|
470
|
+
raise SessionError("Refusing a symlinked session directory.")
|
|
471
|
+
journal = cls(directory, read_info(directory))
|
|
472
|
+
journal._follow()
|
|
473
|
+
return journal
|
|
474
|
+
|
|
475
|
+
def refresh(self) -> bool:
|
|
476
|
+
"""Catch up with what the owner has written since; whether anything changed."""
|
|
477
|
+
changed = self._follow()
|
|
478
|
+
info = read_info(self.directory)
|
|
479
|
+
if info != self.info:
|
|
480
|
+
self.info = info
|
|
481
|
+
changed = True
|
|
482
|
+
return changed
|
|
483
|
+
|
|
484
|
+
def _follow(self) -> bool:
|
|
485
|
+
"""Feed `tree` the complete lines appended since the last call.
|
|
486
|
+
|
|
487
|
+
A final line without its newline may still be being written, so it is
|
|
488
|
+
left for the next call to read whole.
|
|
489
|
+
"""
|
|
490
|
+
path = self.directory / "transcript.jsonl"
|
|
491
|
+
try:
|
|
492
|
+
file = path.open("rb")
|
|
493
|
+
except FileNotFoundError:
|
|
494
|
+
return False
|
|
495
|
+
with file:
|
|
496
|
+
stat = os.fstat(file.fileno())
|
|
497
|
+
identity = (stat.st_dev, stat.st_ino)
|
|
498
|
+
changed = False
|
|
499
|
+
if identity != self._journal_id or stat.st_size < self._offset:
|
|
500
|
+
changed = self._offset > 0 or self._journal_id is not None
|
|
501
|
+
self.tree = ConversationTree()
|
|
502
|
+
self._offset = 0
|
|
503
|
+
self._journal_id = identity
|
|
504
|
+
file.seek(self._offset)
|
|
505
|
+
for line in file:
|
|
506
|
+
if not line.endswith(b"\n"):
|
|
507
|
+
break
|
|
508
|
+
self._offset += len(line)
|
|
509
|
+
changed = True
|
|
510
|
+
if (record := _parse(line)) is not None:
|
|
511
|
+
self.tree.consume(record)
|
|
512
|
+
return changed
|
|
513
|
+
|
|
514
|
+
def journal_size(self) -> int:
|
|
515
|
+
"""Where the next record will start; `end` for records written before now."""
|
|
516
|
+
return (self.directory / "transcript.jsonl").stat().st_size
|
|
517
|
+
|
|
518
|
+
def records(self, end: int | None = None):
|
|
519
|
+
"""Read intact records; a hard kill may leave a torn final append.
|
|
520
|
+
|
|
521
|
+
`end` stops at a `journal_size()` taken earlier, so a turn still being
|
|
522
|
+
written can be left out of what is read.
|
|
523
|
+
"""
|
|
524
|
+
with (self.directory / "transcript.jsonl").open("rb") as file:
|
|
525
|
+
consumed = 0
|
|
526
|
+
for line in file:
|
|
527
|
+
consumed += len(line)
|
|
528
|
+
if end is not None and consumed > end:
|
|
529
|
+
return
|
|
530
|
+
if (record := _parse(line)) is not None:
|
|
531
|
+
yield record
|
|
532
|
+
|
|
533
|
+
def active_records(self, end: int | None = None):
|
|
534
|
+
"""Records on the selected branch, by the turn each one names.
|
|
535
|
+
|
|
536
|
+
`recording` is the pre-run-id fallback: in an older journal a record
|
|
537
|
+
belongs to the last turn started before it, which is only true because
|
|
538
|
+
those sessions could never have two turns open at once.
|
|
539
|
+
"""
|
|
540
|
+
selected = set(self.tree.path(self.tree.active))
|
|
541
|
+
recording = None
|
|
542
|
+
for record in self.records(end):
|
|
543
|
+
if record.get("kind") == "turn_started":
|
|
544
|
+
recording = record.get("run_id")
|
|
545
|
+
if not self.tree.nodes or (record.get("run_id") or recording) in selected:
|
|
546
|
+
yield record
|
|
547
|
+
|
|
548
|
+
def latest_plan(self) -> list[dict]:
|
|
549
|
+
"""Recover UI/tool state even when the last update predates replay's limit."""
|
|
550
|
+
if self.tree.nodes:
|
|
551
|
+
return deepcopy(self.tree.nodes[self.tree.active].plan) if self.tree.active else []
|
|
552
|
+
items = []
|
|
553
|
+
for record in self.active_records():
|
|
554
|
+
if record.get("kind") == "PlanUpdated":
|
|
555
|
+
items = record["items"]
|
|
556
|
+
return items
|
|
557
|
+
|
|
558
|
+
def tool_events(self):
|
|
559
|
+
"""Stream tool lifecycle records independently of the transcript replay limit."""
|
|
560
|
+
for record in self.active_records():
|
|
561
|
+
if record.get("kind") in {
|
|
562
|
+
"ToolStarted",
|
|
563
|
+
"ToolSummary",
|
|
564
|
+
"turn_started",
|
|
565
|
+
"turn_completed",
|
|
566
|
+
"turn_cancelled",
|
|
567
|
+
"turn_failed",
|
|
568
|
+
}:
|
|
569
|
+
yield record
|
|
570
|
+
|
|
571
|
+
def transcript_records(self, end: int | None = None):
|
|
572
|
+
"""Stream active-path display records; Transcript owns the retention budget.
|
|
573
|
+
|
|
574
|
+
Only unfinished text is buffered. Flush thinking before interleaved display
|
|
575
|
+
records, as live output does, and don't repeat it at the completion marker.
|
|
576
|
+
"""
|
|
577
|
+
partial = ""
|
|
578
|
+
thinking = ""
|
|
579
|
+
thinking_streamed = False
|
|
580
|
+
for record in self.active_records(end):
|
|
581
|
+
kind = record.get("kind")
|
|
582
|
+
if kind == "ThinkingDelta":
|
|
583
|
+
thinking += record["text"]
|
|
584
|
+
thinking_streamed = True
|
|
585
|
+
continue
|
|
586
|
+
if kind not in {
|
|
587
|
+
"Thinking",
|
|
588
|
+
"TextDelta",
|
|
589
|
+
"Message",
|
|
590
|
+
"ToolSummary",
|
|
591
|
+
"JobFinished",
|
|
592
|
+
"EditCompleted",
|
|
593
|
+
"CacheBust",
|
|
594
|
+
"steering",
|
|
595
|
+
"turn_started",
|
|
596
|
+
"turn_completed",
|
|
597
|
+
"turn_failed",
|
|
598
|
+
"turn_cancelled",
|
|
599
|
+
}:
|
|
600
|
+
continue
|
|
601
|
+
if thinking:
|
|
602
|
+
yield {"kind": "thinking_partial", "text": thinking}
|
|
603
|
+
thinking = ""
|
|
604
|
+
if kind == "Thinking":
|
|
605
|
+
if not thinking_streamed:
|
|
606
|
+
yield record
|
|
607
|
+
thinking_streamed = False
|
|
608
|
+
elif kind == "TextDelta":
|
|
609
|
+
thinking_streamed = False
|
|
610
|
+
partial += record["text"]
|
|
611
|
+
elif kind == "Message":
|
|
612
|
+
thinking_streamed = False
|
|
613
|
+
partial = ""
|
|
614
|
+
yield record
|
|
615
|
+
elif kind.startswith("turn_"):
|
|
616
|
+
thinking_streamed = False
|
|
617
|
+
if partial:
|
|
618
|
+
yield {"kind": "partial", "markdown": partial}
|
|
619
|
+
partial = ""
|
|
620
|
+
yield record
|
|
621
|
+
else:
|
|
622
|
+
yield record
|
|
623
|
+
if thinking:
|
|
624
|
+
yield {"kind": "thinking_partial", "text": thinking}
|
|
625
|
+
if partial:
|
|
626
|
+
yield {"kind": "partial", "markdown": partial}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class SavedSession(SessionJournal):
|
|
630
|
+
"""The one process that owns a session: holds its lock and writes it."""
|
|
631
|
+
|
|
632
|
+
def __init__(self, directory: Path, info: SessionInfo) -> None:
|
|
633
|
+
super().__init__(directory, info)
|
|
634
|
+
directory.chmod(0o700)
|
|
635
|
+
self.lock = FileLock(directory / ".lock", mode=0o600)
|
|
636
|
+
try:
|
|
637
|
+
self.lock.acquire(timeout=0)
|
|
638
|
+
except Timeout:
|
|
639
|
+
raise SessionBusy("This session is already open in another process.") from None
|
|
640
|
+
try:
|
|
641
|
+
for name in ("steps.sqlite3", "transcript.jsonl", "session.json"):
|
|
642
|
+
private_file(directory / name)
|
|
643
|
+
self.store = PrivateStepStore(
|
|
644
|
+
database=directory / "steps.sqlite3",
|
|
645
|
+
max_snapshots_per_run=SNAPSHOTS_PER_RUN,
|
|
646
|
+
)
|
|
647
|
+
for record in self.records():
|
|
648
|
+
self.tree.consume(record)
|
|
649
|
+
except BaseException:
|
|
650
|
+
self.lock.release()
|
|
651
|
+
raise
|
|
652
|
+
|
|
653
|
+
@classmethod
|
|
654
|
+
def create(
|
|
655
|
+
cls, model: str, workspace: Path, root: Path | None = None, identity: str | None = None
|
|
656
|
+
):
|
|
657
|
+
"""Create a session; `identity` lets a worktree named before the session share its ID."""
|
|
658
|
+
root = root or session_root()
|
|
659
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
660
|
+
identity = identity or str(uuid4())
|
|
661
|
+
directory = root / identity
|
|
662
|
+
directory.mkdir(mode=0o700)
|
|
663
|
+
from pcode.worktree import head_commit, project_checkout
|
|
664
|
+
|
|
665
|
+
project = project_checkout(workspace)
|
|
666
|
+
info = SessionInfo(
|
|
667
|
+
id=identity,
|
|
668
|
+
project=str(project) if project else None,
|
|
669
|
+
start_commit=head_commit(workspace) if project else None,
|
|
670
|
+
model=model,
|
|
671
|
+
workspace=str(workspace.resolve()),
|
|
672
|
+
created=now(),
|
|
673
|
+
updated=now(),
|
|
674
|
+
packages=versions(),
|
|
675
|
+
)
|
|
676
|
+
session = cls(directory, info)
|
|
677
|
+
try:
|
|
678
|
+
session.save_info()
|
|
679
|
+
except BaseException:
|
|
680
|
+
session.close()
|
|
681
|
+
raise
|
|
682
|
+
return session
|
|
683
|
+
|
|
684
|
+
@classmethod
|
|
685
|
+
def open(
|
|
686
|
+
cls,
|
|
687
|
+
selector: str,
|
|
688
|
+
root: Path | None = None,
|
|
689
|
+
workspace: Path | None = None,
|
|
690
|
+
*,
|
|
691
|
+
fork_if_open: bool = False,
|
|
692
|
+
fork: bool = False,
|
|
693
|
+
):
|
|
694
|
+
"""Open a session; `fork_if_open` continues a copy of one open elsewhere.
|
|
695
|
+
|
|
696
|
+
`fork` always continues a copy, leaving the original as it was.
|
|
697
|
+
"""
|
|
698
|
+
path = resolve_session(selector, root, workspace)
|
|
699
|
+
if fork:
|
|
700
|
+
session = cls.fork(path)
|
|
701
|
+
session.fork_requested = True
|
|
702
|
+
return session
|
|
703
|
+
try:
|
|
704
|
+
return cls(path, read_info(path))
|
|
705
|
+
except SessionBusy:
|
|
706
|
+
if not fork_if_open:
|
|
707
|
+
raise
|
|
708
|
+
return cls.fork(path)
|
|
709
|
+
|
|
710
|
+
@classmethod
|
|
711
|
+
def fork(cls, source: Path):
|
|
712
|
+
"""Copy a session, which another process may be writing, to continue it separately.
|
|
713
|
+
|
|
714
|
+
Nothing here takes the source's lock or writes to its directory. The
|
|
715
|
+
journal is copied before the step store, so every turn the copied
|
|
716
|
+
journal calls finished already has its checkpoint in the copied store,
|
|
717
|
+
and the store may be a step ahead of the journal but never behind it. A
|
|
718
|
+
torn final journal line is skipped on read, as after a crash.
|
|
719
|
+
|
|
720
|
+
A turn still running in the source is copied the way a crash leaves
|
|
721
|
+
one: recovery continues from its last settled step, or from the turn
|
|
722
|
+
before it when it has settled none, and `/resend` carries it on.
|
|
723
|
+
"""
|
|
724
|
+
for name in ("session.json", "transcript.jsonl", "steps.sqlite3"):
|
|
725
|
+
if (source / name).is_symlink() or not (source / name).is_file():
|
|
726
|
+
raise SessionError("The session to copy is missing or symlinked.")
|
|
727
|
+
info = read_info(source)
|
|
728
|
+
identity = str(uuid4())
|
|
729
|
+
directory = source.parent / identity
|
|
730
|
+
directory.mkdir(mode=0o700)
|
|
731
|
+
try:
|
|
732
|
+
for name in ("transcript.jsonl", "steps.sqlite3"):
|
|
733
|
+
private_file(directory / name)
|
|
734
|
+
shutil.copyfile(source / "transcript.jsonl", directory / "transcript.jsonl")
|
|
735
|
+
# The backup API reads one consistent snapshot of a WAL store that
|
|
736
|
+
# its owner keeps writing; a file copy could tear it.
|
|
737
|
+
origin = sqlite3.connect(source / "steps.sqlite3")
|
|
738
|
+
try:
|
|
739
|
+
copy = sqlite3.connect(directory / "steps.sqlite3")
|
|
740
|
+
try:
|
|
741
|
+
origin.backup(copy)
|
|
742
|
+
finally:
|
|
743
|
+
copy.close()
|
|
744
|
+
finally:
|
|
745
|
+
origin.close()
|
|
746
|
+
stamp = now()
|
|
747
|
+
info = info.model_copy(
|
|
748
|
+
update={"id": identity, "created": stamp, "updated": stamp, "packages": versions()}
|
|
749
|
+
)
|
|
750
|
+
session = cls(directory, info)
|
|
751
|
+
except BaseException:
|
|
752
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
753
|
+
raise
|
|
754
|
+
session.forked_from = source.name
|
|
755
|
+
try:
|
|
756
|
+
# Written last: until the manifest exists, listings skip the copy.
|
|
757
|
+
session.save_info()
|
|
758
|
+
except BaseException:
|
|
759
|
+
session.abandon()
|
|
760
|
+
raise
|
|
761
|
+
return session
|
|
762
|
+
|
|
763
|
+
def abandon(self) -> None:
|
|
764
|
+
"""Close after a resume that did not go ahead; a copy made for it is removed."""
|
|
765
|
+
try:
|
|
766
|
+
if self.forked_from:
|
|
767
|
+
shutil.rmtree(self.directory, ignore_errors=True)
|
|
768
|
+
finally:
|
|
769
|
+
self.close()
|
|
770
|
+
|
|
771
|
+
def save_info(self, *, touch: bool = True) -> None:
|
|
772
|
+
"""Write the manifest; `touch=False` keeps `updated`, the /resume sort and date."""
|
|
773
|
+
if touch:
|
|
774
|
+
self.info.updated = now()
|
|
775
|
+
# Atomic replacement: interruption cannot leave half a manifest.
|
|
776
|
+
temporary = None
|
|
777
|
+
try:
|
|
778
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=self.directory, delete=False) as file:
|
|
779
|
+
temporary = Path(file.name)
|
|
780
|
+
file.write(self.info.model_dump_json(indent=2))
|
|
781
|
+
file.flush()
|
|
782
|
+
os.fsync(file.fileno())
|
|
783
|
+
temporary.replace(self.directory / "session.json")
|
|
784
|
+
finally:
|
|
785
|
+
if temporary is not None:
|
|
786
|
+
temporary.unlink(missing_ok=True)
|
|
787
|
+
|
|
788
|
+
def append(self, kind: str, *, sync: bool = False, **data) -> None:
|
|
789
|
+
record = {"version": 1, "time": now(), "kind": kind, **data}
|
|
790
|
+
# Conversation content is intentionally retained losslessly. The directory
|
|
791
|
+
# is private; credentials/transport headers are never passed to this API.
|
|
792
|
+
with (self.directory / "transcript.jsonl").open("ab+") as file:
|
|
793
|
+
if file.tell():
|
|
794
|
+
file.seek(-1, os.SEEK_END)
|
|
795
|
+
if file.read(1) != b"\n":
|
|
796
|
+
# Preserve a torn final record for debugging, but don't glue
|
|
797
|
+
# the next valid event onto it after a restart.
|
|
798
|
+
file.write(b"\n")
|
|
799
|
+
file.write((json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
800
|
+
file.flush()
|
|
801
|
+
if sync:
|
|
802
|
+
os.fsync(file.fileno())
|
|
803
|
+
|
|
804
|
+
self.tree.consume(record)
|
|
805
|
+
|
|
806
|
+
def record_error(
|
|
807
|
+
self,
|
|
808
|
+
error: BaseException,
|
|
809
|
+
*,
|
|
810
|
+
run_id: str,
|
|
811
|
+
provider_context: dict[str, str] | None = None,
|
|
812
|
+
detail: str = "",
|
|
813
|
+
) -> Path | None:
|
|
814
|
+
"""Append the traceback the transcript's bounded `error` summary cannot carry.
|
|
815
|
+
|
|
816
|
+
A type and a message name the symptom; only frames name the line. This
|
|
817
|
+
is a separate append-only file rather than a transcript record so a
|
|
818
|
+
crash loop cannot push conversation history out of a replay window, and
|
|
819
|
+
so nothing that renders the transcript has to filter it out.
|
|
820
|
+
|
|
821
|
+
Diagnostics must never replace the failure being diagnosed: a directory
|
|
822
|
+
that has gone away or read-only is silently accepted.
|
|
823
|
+
"""
|
|
824
|
+
path = self.directory / "errors.log"
|
|
825
|
+
try:
|
|
826
|
+
private_file(path)
|
|
827
|
+
with path.open("a", encoding="utf-8") as file:
|
|
828
|
+
file.write(f"--- {now()} run {run_id} ---\n")
|
|
829
|
+
if detail:
|
|
830
|
+
file.write(redact(detail) + "\n")
|
|
831
|
+
if provider_context:
|
|
832
|
+
file.write(
|
|
833
|
+
"Configured provider: " + redact(json.dumps(provider_context)) + "\n"
|
|
834
|
+
)
|
|
835
|
+
file.write(f"{error_report(error)}\n")
|
|
836
|
+
except OSError:
|
|
837
|
+
return None
|
|
838
|
+
return path
|
|
839
|
+
|
|
840
|
+
def event(self, event, *, run_id: str = "") -> None:
|
|
841
|
+
"""Record a display event, named with the turn that produced it.
|
|
842
|
+
|
|
843
|
+
Turn membership used to be inferred from file order, which only holds
|
|
844
|
+
while one turn runs at a time. An event that names its own run (tool
|
|
845
|
+
events already do) keeps that name; everything else takes the caller's.
|
|
846
|
+
"""
|
|
847
|
+
data = asdict(event)
|
|
848
|
+
data["run_id"] = data.get("run_id") or run_id
|
|
849
|
+
self.append(type(event).__name__, **data)
|
|
850
|
+
|
|
851
|
+
def refresh(self) -> bool:
|
|
852
|
+
"""The owner's tree and manifest are already current, and hold unsaved state
|
|
853
|
+
(navigation, unsaved turns' history) that re-reading the files would drop."""
|
|
854
|
+
return False
|
|
855
|
+
|
|
856
|
+
async def history_at(self, identity: str | None):
|
|
857
|
+
path = self.tree.path(identity)
|
|
858
|
+
for run_id in reversed(path):
|
|
859
|
+
node = self.tree.nodes[run_id]
|
|
860
|
+
if node.kind == "compaction":
|
|
861
|
+
return deepcopy(node.history)
|
|
862
|
+
# Never opt into interrupted snapshots with pending tool calls.
|
|
863
|
+
snapshot = await self.store.latest_snapshot(run_id=run_id)
|
|
864
|
+
if snapshot is not None:
|
|
865
|
+
return snapshot.messages
|
|
866
|
+
if self.tree.nodes[run_id].status == "completed":
|
|
867
|
+
raise SessionError("The selected turn's checkpoint is missing; branch unchanged.")
|
|
868
|
+
return []
|
|
869
|
+
|
|
870
|
+
async def recover(self):
|
|
871
|
+
"""Restore settled history without replaying tools or resolving their effects.
|
|
872
|
+
|
|
873
|
+
Interrupted tools may have changed the workspace. Keep their ledger entries
|
|
874
|
+
intact for diagnostics, but do not require review to continue the session.
|
|
875
|
+
"""
|
|
876
|
+
if self.tree.nodes:
|
|
877
|
+
return await self.history_at(self.tree.active)
|
|
878
|
+
# Compatibility with sessions whose journal predates run IDs. Delegated
|
|
879
|
+
# runs are stored too, and a sub-agent's history is not this conversation.
|
|
880
|
+
runs = [
|
|
881
|
+
run
|
|
882
|
+
for run in await self.store.list_runs(conversation_id=self.info.id)
|
|
883
|
+
if run.parent_run_id is None
|
|
884
|
+
]
|
|
885
|
+
for run in reversed(runs):
|
|
886
|
+
snapshot = await self.store.latest_snapshot(run_id=run.run_id)
|
|
887
|
+
if snapshot is not None:
|
|
888
|
+
return snapshot.messages
|
|
889
|
+
return []
|
|
890
|
+
|
|
891
|
+
def close(self) -> None:
|
|
892
|
+
self.lock.release()
|