Repository navigation
Expand file tree
/
Copy pathsession.py
More file actions
240 lines (220 loc) · 8.54 KB
/
Copy pathsession.py
File metadata and controls
240 lines (220 loc) · 8.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
"""Canonical session boundary."""
import json
import os
from pathlib import Path
import tempfile
import time
class SessionStore:
SCHEMA_VERSION = 3
def __init__(self, path):
self.path = Path(path)
self.path.mkdir(parents=True, exist_ok=True)
self.lock_path = self.path / "writer.lock"
self._locked = False
self._seq = self._tail_seq()
self.event_handler = None
self.listener_errors = []
# -- single-writer lock ------------------------------------
def acquire_lock(self, stale_seconds=3600):
if self._locked:
return
import socket
payload = json.dumps({"pid": os.getpid(), "host": socket.gethostname(), "at": time.time()})
try:
fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(payload)
self._locked = True
self._seq = self._tail_seq()
return
except FileExistsError:
pass
# stale detection: holder pid dead or lock too old -> steal
try:
info = json.loads(self.lock_path.read_text(encoding="utf-8"))
except Exception:
info = {}
pid_alive = False
try:
if os.name == "nt":
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(0x1000, False, int(info.get("pid", 0)))
if handle:
kernel32.CloseHandle(handle)
pid_alive = True
else:
os.kill(int(info.get("pid", 0)), 0)
pid_alive = True
except Exception:
pid_alive = False
lock_age = time.time() - float(info.get("at", 0))
# Age never permits stealing a live writer, including long GPU jobs.
if info.get("host") not in (None, socket.gethostname()) or pid_alive:
raise RuntimeError(
f"session locked by pid={info.get('pid')} on "
f"{info.get('host')} (age {lock_age:.0f}s); single-writer rule"
)
try:
self.lock_path.unlink()
except FileNotFoundError:
pass
return self.acquire_lock()
def release_lock(self):
if self._locked:
try:
self.lock_path.unlink()
except FileNotFoundError:
pass
self._locked = False
# -- event sequence numbers ---------------------------------
def _tail_seq(self):
p = self.path / "events.jsonl"
if not p.exists():
return 0
try:
last = p.read_text(encoding="utf-8").splitlines()[-1]
return int(json.loads(last).get("seq", 0))
except Exception:
return 0
def save(self, state):
target = self.path / "state.json"
fd, tmp = tempfile.mkstemp(dir=self.path, prefix="state-")
try:
state = dict(state)
state.setdefault("schema_version", self.SCHEMA_VERSION)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, target)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def load(self):
p = self.path / "state.json"
if not p.exists():
return None
state = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(state, dict):
raise ValueError("Session state must be a JSON object")
if state.get("schema_version", 0) != self.SCHEMA_VERSION:
raise RuntimeError(
f"session schema {state.get('schema_version')} != "
f"{self.SCHEMA_VERSION}; migrate or start a new session"
)
return state
def event(self, kind, data):
self._seq += 1
event = {"seq": self._seq, "time": time.time(), "kind": kind, "data": data}
with (self.path / "events.jsonl").open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
f.flush()
if self.event_handler:
try:
self.event_handler(event)
except Exception as e:
self.listener_errors = (self.listener_errors + [type(e).__name__ + ": " + str(e)])[
-8:
]
def request_control(self, action):
if action not in ("pause", "cancel"):
raise ValueError("control must be pause or cancel")
target = self.path / "control.json"
fd, temporary = tempfile.mkstemp(dir=self.path, prefix="control-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"action": action}, f)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def take_control(self):
target = self.path / "control.json"
if not target.exists():
return None
value = json.loads(target.read_text(encoding="utf-8"))
target.unlink()
if value.get("action") not in ("pause", "cancel"):
raise ValueError("invalid control request")
return value["action"]
def events(self, after=0):
target = self.path / "events.jsonl"
if not target.exists():
return []
result = []
for line in target.read_text(encoding="utf-8").splitlines():
item = json.loads(line)
if item.get("seq", 0) > after:
result.append(item)
return result
def capture_before(self, workspace, path):
import hashlib
source = workspace.path(path)
key = hashlib.sha256(path.encode()).hexdigest()
folder = self.path / "before"
folder.mkdir(exist_ok=True)
target = folder / (key + ".json")
if not target.exists():
content = source.read_text(encoding="utf-8") if source.exists() else ""
target.write_text(
json.dumps({"path": path, "existed": source.exists(), "source": content}),
encoding="utf-8",
)
def diff(self):
import difflib
from infinicoder.execution.workspace_tools import Workspace
state = self.load()
if not state:
return ""
ws = Workspace(state["workspace"])
patches = []
for path in sorted((self.path / "before").glob("*.json")):
value = json.loads(path.read_text(encoding="utf-8"))
current = ws.path(value["path"])
content = current.read_text(encoding="utf-8") if current.exists() else ""
patches.extend(
difflib.unified_diff(
value["source"].splitlines(True),
content.splitlines(True),
fromfile="before/" + value["path"],
tofile="after/" + value["path"],
)
)
return "".join(patches)
def migrate(self):
"""Explicit additive v1/v2 migration; reject unknown or incomplete states."""
self.acquire_lock()
try:
target = self.path / "state.json"
state = json.loads(target.read_text(encoding="utf-8"))
old = state.get("schema_version")
if old == self.SCHEMA_VERSION:
return {"changed": False, "schema_version": old}
required = {
"contract",
"workspace",
"steps",
"milestone",
"notes",
"recent",
"changed_files",
"evidence",
"failures",
"usage",
"status",
"session_deadline_epoch",
}
if old not in (1, 2) or required - set(state):
raise ValueError(
"unknown/incomplete historical session; inspect and start a new session"
)
backup = self.path / ("state.pre-v3-" + str(time.time_ns()) + ".json")
backup.write_bytes(target.read_bytes())
state["schema_version"] = self.SCHEMA_VERSION
state.setdefault("amendments", 0)
self.save(state)
self.event("migration", {"from": old, "to": self.SCHEMA_VERSION, "backup": str(backup)})
return {"changed": True, "schema_version": self.SCHEMA_VERSION, "backup": str(backup)}
finally:
self.release_lock()