@openlucaskaka/kagent 0.1.7
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/README.md +353 -0
- package/npm/bin/kagent-serve.js +6 -0
- package/npm/bin/kagent.js +6 -0
- package/npm/lib/App.js +524 -0
- package/npm/lib/app-state.js +224 -0
- package/npm/lib/approval-choice.js +25 -0
- package/npm/lib/commands.js +59 -0
- package/npm/lib/editor.js +188 -0
- package/npm/lib/ink-runner.js +41 -0
- package/npm/lib/kagent-home.js +39 -0
- package/npm/lib/launcher.js +221 -0
- package/npm/lib/protocol.js +33 -0
- package/npm/lib/provider-setup.js +139 -0
- package/npm/lib/python-runner.js +892 -0
- package/npm/lib/runtime-client.js +390 -0
- package/npm/lib/terminal-input.js +127 -0
- package/npm/lib/terminal-text.js +19 -0
- package/npm/lib/terminal-width.js +14 -0
- package/npm/lib/transcript.js +227 -0
- package/npm/lib/ui-components.js +247 -0
- package/npm/lib/update-manager.js +334 -0
- package/package.json +39 -0
- package/pyproject.toml +55 -0
- package/src/kagent/__init__.py +90 -0
- package/src/kagent/cli/__init__.py +5 -0
- package/src/kagent/cli/__main__.py +6 -0
- package/src/kagent/cli/commands.py +112 -0
- package/src/kagent/cli/conversation.py +127 -0
- package/src/kagent/cli/interactive.py +841 -0
- package/src/kagent/cli/main.py +685 -0
- package/src/kagent/cli/memory.py +460 -0
- package/src/kagent/cli/pending_approval.py +169 -0
- package/src/kagent/cli/provider.py +27 -0
- package/src/kagent/cli/session_commands.py +401 -0
- package/src/kagent/cli/stdio_runtime.py +784 -0
- package/src/kagent/cli/trace.py +67 -0
- package/src/kagent/cli/ui.py +931 -0
- package/src/kagent/core/__init__.py +0 -0
- package/src/kagent/core/agent.py +296 -0
- package/src/kagent/core/faults.py +11 -0
- package/src/kagent/core/invariants.py +47 -0
- package/src/kagent/core/normalization.py +81 -0
- package/src/kagent/core/planning.py +48 -0
- package/src/kagent/core/state.py +73 -0
- package/src/kagent/core/summary.py +64 -0
- package/src/kagent/core/tools.py +196 -0
- package/src/kagent/core/trace.py +57 -0
- package/src/kagent/eval/__init__.py +11 -0
- package/src/kagent/eval/cases.py +229 -0
- package/src/kagent/eval/evaluator.py +216 -0
- package/src/kagent/integrations/__init__.py +3 -0
- package/src/kagent/integrations/audit.py +95 -0
- package/src/kagent/integrations/backends.py +131 -0
- package/src/kagent/integrations/memory.py +301 -0
- package/src/kagent/ops/__init__.py +0 -0
- package/src/kagent/ops/batch.py +156 -0
- package/src/kagent/ops/doctor.py +255 -0
- package/src/kagent/ops/metrics.py +214 -0
- package/src/kagent/ops/release_evidence.py +877 -0
- package/src/kagent/ops/release_manifest.py +142 -0
- package/src/kagent/ops/trace_replay.py +285 -0
- package/src/kagent/providers/__init__.py +7 -0
- package/src/kagent/providers/embeddings.py +187 -0
- package/src/kagent/providers/llm.py +770 -0
- package/src/kagent/py.typed +1 -0
- package/src/kagent/runtime/__init__.py +28 -0
- package/src/kagent/runtime/action_graph.py +543 -0
- package/src/kagent/runtime/agent.py +2089 -0
- package/src/kagent/runtime/approval.py +64 -0
- package/src/kagent/runtime/cancellation.py +64 -0
- package/src/kagent/runtime/checkpoint_state.py +146 -0
- package/src/kagent/runtime/checkpoint_storage.py +270 -0
- package/src/kagent/runtime/context.py +65 -0
- package/src/kagent/runtime/file_transaction.py +195 -0
- package/src/kagent/runtime/hooks.py +74 -0
- package/src/kagent/runtime/metadata.py +116 -0
- package/src/kagent/runtime/patch_checkpoints.py +385 -0
- package/src/kagent/runtime/policy.py +50 -0
- package/src/kagent/runtime/presentation.py +205 -0
- package/src/kagent/runtime/redaction.py +130 -0
- package/src/kagent/runtime/sandbox.py +331 -0
- package/src/kagent/runtime/skills.py +66 -0
- package/src/kagent/runtime/steering.py +56 -0
- package/src/kagent/runtime/steps.py +255 -0
- package/src/kagent/runtime/task_state.py +40 -0
- package/src/kagent/runtime/tools.py +3532 -0
- package/src/kagent/runtime/types.py +240 -0
- package/src/kagent/runtime/workspace.py +597 -0
- package/src/kagent/service/__init__.py +26 -0
- package/src/kagent/service/__main__.py +6 -0
- package/src/kagent/service/active_runs.py +178 -0
- package/src/kagent/service/cli.py +737 -0
- package/src/kagent/service/contract.py +2571 -0
- package/src/kagent/service/errors.py +71 -0
- package/src/kagent/service/idempotency.py +584 -0
- package/src/kagent/service/router.py +884 -0
- package/src/kagent/service/run.py +150 -0
- package/src/kagent/service/runtime.py +1915 -0
- package/src/kagent/service/runtime_approval.py +20 -0
- package/src/kagent/service/runtime_cancel.py +192 -0
- package/src/kagent/service/runtime_lifecycle.py +229 -0
- package/src/kagent/service/runtime_metadata.py +21 -0
- package/src/kagent/service/runtime_policy.py +115 -0
- package/src/kagent/service/runtime_recovery.py +573 -0
- package/src/kagent/service/runtime_resume.py +382 -0
- package/src/kagent/service/runtime_resume_claim.py +116 -0
- package/src/kagent/service/runtime_run.py +350 -0
- package/src/kagent/service/runtime_status.py +2007 -0
- package/src/kagent/service/safety.py +139 -0
- package/src/kagent/service/server.py +114 -0
- package/src/kagent/service/status.py +233 -0
- package/src/kagent/service/trace_store.py +322 -0
- package/src/kagent/service/transport.py +24 -0
- package/src/kagent/utils/__init__.py +0 -0
- package/src/kagent/utils/config_validation.py +21 -0
- package/src/kagent/utils/json_output.py +23 -0
- package/src/kagent/utils/paths.py +473 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from threading import Event, Lock, Thread, current_thread
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from kagent.runtime import RUNTIME_TRACE_TYPE
|
|
14
|
+
from kagent.service.active_runs import ActiveRunRegistry
|
|
15
|
+
from kagent.service.errors import AGENT_RUN_INTERRUPTED
|
|
16
|
+
from kagent.service.safety import safe_trace_file_stem
|
|
17
|
+
from kagent.service.trace_store import (
|
|
18
|
+
load_trace_by_run_id,
|
|
19
|
+
persist_trace,
|
|
20
|
+
runtime_trace_lock,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
RUNTIME_INTERRUPTED_ERROR_CODE = AGENT_RUN_INTERRUPTED
|
|
24
|
+
APPROVAL_EXECUTION_INTERRUPTED_ERROR_CODE = "approval_execution_interrupted"
|
|
25
|
+
_INSTANCE_DIRECTORY = ".runtime-instances"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RuntimeInstanceLease:
|
|
29
|
+
"""Heartbeat lease used to distinguish live workers from orphaned traces."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
trace_dir: str,
|
|
34
|
+
*,
|
|
35
|
+
instance_id: str,
|
|
36
|
+
heartbeat_seconds: float,
|
|
37
|
+
) -> None:
|
|
38
|
+
if heartbeat_seconds <= 0:
|
|
39
|
+
raise ValueError("heartbeat_seconds must be positive")
|
|
40
|
+
self.trace_dir = trace_dir
|
|
41
|
+
self.instance_id = instance_id
|
|
42
|
+
self.heartbeat_seconds = heartbeat_seconds
|
|
43
|
+
self._started_at = _utc_timestamp()
|
|
44
|
+
self._stop_event = Event()
|
|
45
|
+
self._lock = Lock()
|
|
46
|
+
self._thread: Optional[Thread] = None
|
|
47
|
+
self._shutdown_watcher_started = False
|
|
48
|
+
|
|
49
|
+
def start(self) -> None:
|
|
50
|
+
with self._lock:
|
|
51
|
+
if self._thread is not None:
|
|
52
|
+
return
|
|
53
|
+
self._write_heartbeat()
|
|
54
|
+
thread = Thread(
|
|
55
|
+
target=self._heartbeat_loop,
|
|
56
|
+
name=f"kagent-runtime-heartbeat-{self.instance_id[:8]}",
|
|
57
|
+
daemon=True,
|
|
58
|
+
)
|
|
59
|
+
self._thread = thread
|
|
60
|
+
thread.start()
|
|
61
|
+
|
|
62
|
+
def stop(self) -> None:
|
|
63
|
+
self._stop_event.set()
|
|
64
|
+
with self._lock:
|
|
65
|
+
thread = self._thread
|
|
66
|
+
self._thread = None
|
|
67
|
+
if thread is not None and thread is not current_thread():
|
|
68
|
+
thread.join(timeout=max(1.0, self.heartbeat_seconds * 2))
|
|
69
|
+
try:
|
|
70
|
+
_lease_path(self.trace_dir, self.instance_id).unlink(missing_ok=True)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def stop_when_idle(self, registry: ActiveRunRegistry) -> None:
|
|
75
|
+
if registry.is_empty():
|
|
76
|
+
self.stop()
|
|
77
|
+
return
|
|
78
|
+
with self._lock:
|
|
79
|
+
if self._shutdown_watcher_started:
|
|
80
|
+
return
|
|
81
|
+
self._shutdown_watcher_started = True
|
|
82
|
+
|
|
83
|
+
def wait_for_workers() -> None:
|
|
84
|
+
while not registry.is_empty():
|
|
85
|
+
time.sleep(min(0.1, self.heartbeat_seconds))
|
|
86
|
+
self.stop()
|
|
87
|
+
|
|
88
|
+
Thread(
|
|
89
|
+
target=wait_for_workers,
|
|
90
|
+
name=f"kagent-runtime-lease-drain-{self.instance_id[:8]}",
|
|
91
|
+
daemon=True,
|
|
92
|
+
).start()
|
|
93
|
+
|
|
94
|
+
def snapshot(self) -> Dict[str, str]:
|
|
95
|
+
return {
|
|
96
|
+
"runtime_instance_id": self.instance_id,
|
|
97
|
+
"runtime_instance_started_at": self._started_at,
|
|
98
|
+
"runtime_instance_heartbeat_seconds": str(self.heartbeat_seconds),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def _heartbeat_loop(self) -> None:
|
|
102
|
+
while not self._stop_event.wait(self.heartbeat_seconds):
|
|
103
|
+
try:
|
|
104
|
+
self._write_heartbeat()
|
|
105
|
+
except OSError:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
def _write_heartbeat(self) -> None:
|
|
109
|
+
_persist_private_json(
|
|
110
|
+
_lease_path(self.trace_dir, self.instance_id),
|
|
111
|
+
{
|
|
112
|
+
"runtime_instance_id": self.instance_id,
|
|
113
|
+
"started_at": self._started_at,
|
|
114
|
+
"heartbeat_at": _utc_timestamp(),
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def reconcile_orphaned_runtime_traces(
|
|
120
|
+
trace_dir: str,
|
|
121
|
+
*,
|
|
122
|
+
current_instance_id: str,
|
|
123
|
+
stale_after_seconds: float,
|
|
124
|
+
now: Optional[datetime] = None,
|
|
125
|
+
) -> Dict[str, Any]:
|
|
126
|
+
if stale_after_seconds <= 0:
|
|
127
|
+
raise ValueError("stale_after_seconds must be positive")
|
|
128
|
+
current_time = now or datetime.now(timezone.utc)
|
|
129
|
+
summary: Dict[str, Any] = {
|
|
130
|
+
"scanned": 0,
|
|
131
|
+
"recovered_running": 0,
|
|
132
|
+
"completed_resumes": 0,
|
|
133
|
+
"reopened_approvals": 0,
|
|
134
|
+
"protected_live": 0,
|
|
135
|
+
"skipped_unowned": 0,
|
|
136
|
+
"skipped_locked": 0,
|
|
137
|
+
"errors": [],
|
|
138
|
+
}
|
|
139
|
+
traces = _runtime_trace_candidates(trace_dir, summary)
|
|
140
|
+
lease_cache: Dict[str, bool] = {}
|
|
141
|
+
|
|
142
|
+
for trace in traces:
|
|
143
|
+
if trace.get("status") != "running":
|
|
144
|
+
continue
|
|
145
|
+
owner_id = str(trace.get("runtime_instance_id", ""))
|
|
146
|
+
if not _owner_is_orphaned(
|
|
147
|
+
trace_dir,
|
|
148
|
+
owner_id,
|
|
149
|
+
current_instance_id,
|
|
150
|
+
stale_after_seconds,
|
|
151
|
+
current_time,
|
|
152
|
+
lease_cache,
|
|
153
|
+
summary,
|
|
154
|
+
):
|
|
155
|
+
continue
|
|
156
|
+
outcome = _reconcile_running_trace(
|
|
157
|
+
trace_dir,
|
|
158
|
+
trace,
|
|
159
|
+
current_instance_id,
|
|
160
|
+
stale_after_seconds,
|
|
161
|
+
current_time,
|
|
162
|
+
)
|
|
163
|
+
_record_outcome(summary, outcome, "recovered_running")
|
|
164
|
+
|
|
165
|
+
for trace in traces:
|
|
166
|
+
if trace.get("status") != "resuming":
|
|
167
|
+
continue
|
|
168
|
+
owner_id = str(trace.get("resume_runtime_instance_id", ""))
|
|
169
|
+
if not _owner_is_orphaned(
|
|
170
|
+
trace_dir,
|
|
171
|
+
owner_id,
|
|
172
|
+
current_instance_id,
|
|
173
|
+
stale_after_seconds,
|
|
174
|
+
current_time,
|
|
175
|
+
lease_cache,
|
|
176
|
+
summary,
|
|
177
|
+
):
|
|
178
|
+
continue
|
|
179
|
+
outcome = _reconcile_resuming_trace(
|
|
180
|
+
trace_dir,
|
|
181
|
+
trace,
|
|
182
|
+
current_instance_id,
|
|
183
|
+
stale_after_seconds,
|
|
184
|
+
current_time,
|
|
185
|
+
)
|
|
186
|
+
if outcome == "completed":
|
|
187
|
+
summary["completed_resumes"] += 1
|
|
188
|
+
elif outcome == "reopened":
|
|
189
|
+
summary["reopened_approvals"] += 1
|
|
190
|
+
elif outcome == "locked":
|
|
191
|
+
summary["skipped_locked"] += 1
|
|
192
|
+
elif outcome.startswith("error:"):
|
|
193
|
+
summary["errors"].append(outcome.removeprefix("error:"))
|
|
194
|
+
return summary
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def runtime_instance_is_stale(
|
|
198
|
+
trace_dir: str,
|
|
199
|
+
instance_id: str,
|
|
200
|
+
*,
|
|
201
|
+
stale_after_seconds: float,
|
|
202
|
+
now: Optional[datetime] = None,
|
|
203
|
+
) -> bool:
|
|
204
|
+
path = _lease_path(trace_dir, instance_id)
|
|
205
|
+
try:
|
|
206
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
207
|
+
except (OSError, json.JSONDecodeError):
|
|
208
|
+
return True
|
|
209
|
+
if not isinstance(payload, dict) or payload.get("runtime_instance_id") != instance_id:
|
|
210
|
+
return True
|
|
211
|
+
heartbeat_at = _parse_timestamp(payload.get("heartbeat_at"))
|
|
212
|
+
if heartbeat_at is None:
|
|
213
|
+
return True
|
|
214
|
+
current_time = now or datetime.now(timezone.utc)
|
|
215
|
+
return (current_time - heartbeat_at).total_seconds() > stale_after_seconds
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _runtime_trace_candidates(trace_dir: str, summary: Dict[str, Any]) -> list[Dict[str, Any]]:
|
|
219
|
+
traces = []
|
|
220
|
+
for path in sorted(Path(trace_dir).glob("*.json")):
|
|
221
|
+
if path.is_symlink() or not path.is_file():
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
225
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
226
|
+
summary["errors"].append(f"{path}: {exc}")
|
|
227
|
+
continue
|
|
228
|
+
if not isinstance(payload, dict) or payload.get("trace_type") != RUNTIME_TRACE_TYPE:
|
|
229
|
+
continue
|
|
230
|
+
summary["scanned"] += 1
|
|
231
|
+
traces.append(payload)
|
|
232
|
+
return traces
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _owner_is_orphaned(
|
|
236
|
+
trace_dir: str,
|
|
237
|
+
owner_id: str,
|
|
238
|
+
current_instance_id: str,
|
|
239
|
+
stale_after_seconds: float,
|
|
240
|
+
now: datetime,
|
|
241
|
+
lease_cache: Dict[str, bool],
|
|
242
|
+
summary: Dict[str, Any],
|
|
243
|
+
) -> bool:
|
|
244
|
+
if not owner_id:
|
|
245
|
+
summary["skipped_unowned"] += 1
|
|
246
|
+
return False
|
|
247
|
+
if owner_id == current_instance_id:
|
|
248
|
+
summary["protected_live"] += 1
|
|
249
|
+
return False
|
|
250
|
+
try:
|
|
251
|
+
if owner_id not in lease_cache:
|
|
252
|
+
lease_cache[owner_id] = runtime_instance_is_stale(
|
|
253
|
+
trace_dir,
|
|
254
|
+
owner_id,
|
|
255
|
+
stale_after_seconds=stale_after_seconds,
|
|
256
|
+
now=now,
|
|
257
|
+
)
|
|
258
|
+
stale = lease_cache[owner_id]
|
|
259
|
+
except ValueError as exc:
|
|
260
|
+
summary["errors"].append(f"runtime instance {owner_id!r}: {exc}")
|
|
261
|
+
return False
|
|
262
|
+
if not stale:
|
|
263
|
+
summary["protected_live"] += 1
|
|
264
|
+
return stale
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _reconcile_running_trace(
|
|
268
|
+
trace_dir: str,
|
|
269
|
+
trace: Dict[str, Any],
|
|
270
|
+
current_instance_id: str,
|
|
271
|
+
stale_after_seconds: float,
|
|
272
|
+
now: datetime,
|
|
273
|
+
) -> str:
|
|
274
|
+
run_id = str(trace.get("run_id", ""))
|
|
275
|
+
try:
|
|
276
|
+
with _TraceReconcileLock(
|
|
277
|
+
trace_dir,
|
|
278
|
+
run_id,
|
|
279
|
+
instance_id=current_instance_id,
|
|
280
|
+
stale_after_seconds=stale_after_seconds,
|
|
281
|
+
now=now,
|
|
282
|
+
) as acquired:
|
|
283
|
+
if not acquired:
|
|
284
|
+
return "locked"
|
|
285
|
+
with runtime_trace_lock(run_id, trace_dir):
|
|
286
|
+
current = load_trace_by_run_id(run_id, trace_dir)
|
|
287
|
+
if current is None or current.get("status") != "running":
|
|
288
|
+
return "unchanged"
|
|
289
|
+
completed_at = now.isoformat()
|
|
290
|
+
approval_execution_interrupted = bool(
|
|
291
|
+
str(current.get("resumed_from_run_id", "")).strip()
|
|
292
|
+
)
|
|
293
|
+
error_code = (
|
|
294
|
+
APPROVAL_EXECUTION_INTERRUPTED_ERROR_CODE
|
|
295
|
+
if approval_execution_interrupted
|
|
296
|
+
else RUNTIME_INTERRUPTED_ERROR_CODE
|
|
297
|
+
)
|
|
298
|
+
error = (
|
|
299
|
+
"approved action execution was interrupted; side-effect state is uncertain"
|
|
300
|
+
if approval_execution_interrupted
|
|
301
|
+
else "runtime worker owner heartbeat expired"
|
|
302
|
+
)
|
|
303
|
+
current["status"] = "failed"
|
|
304
|
+
current["completed_at"] = completed_at
|
|
305
|
+
current["interrupted_at"] = completed_at
|
|
306
|
+
current["error_code"] = error_code
|
|
307
|
+
current["error"] = error
|
|
308
|
+
current["orphaned_runtime_instance_id"] = current.get(
|
|
309
|
+
"runtime_instance_id",
|
|
310
|
+
"",
|
|
311
|
+
)
|
|
312
|
+
current["reconciled_by_runtime_instance_id"] = current_instance_id
|
|
313
|
+
current["reconciled_at"] = completed_at
|
|
314
|
+
current.pop("pending_approval", None)
|
|
315
|
+
_append_recovery_event(current, completed_at)
|
|
316
|
+
_refresh_duration(current, now)
|
|
317
|
+
current["trace_path"] = persist_trace(current, trace_dir)
|
|
318
|
+
return "recovered"
|
|
319
|
+
except (OSError, ValueError) as exc:
|
|
320
|
+
return f"error:{run_id}: {exc}"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _reconcile_resuming_trace(
|
|
324
|
+
trace_dir: str,
|
|
325
|
+
trace: Dict[str, Any],
|
|
326
|
+
current_instance_id: str,
|
|
327
|
+
stale_after_seconds: float,
|
|
328
|
+
now: datetime,
|
|
329
|
+
) -> str:
|
|
330
|
+
run_id = str(trace.get("run_id", ""))
|
|
331
|
+
try:
|
|
332
|
+
with _TraceReconcileLock(
|
|
333
|
+
trace_dir,
|
|
334
|
+
run_id,
|
|
335
|
+
instance_id=current_instance_id,
|
|
336
|
+
stale_after_seconds=stale_after_seconds,
|
|
337
|
+
now=now,
|
|
338
|
+
) as acquired:
|
|
339
|
+
if not acquired:
|
|
340
|
+
return "locked"
|
|
341
|
+
with runtime_trace_lock(run_id, trace_dir):
|
|
342
|
+
current = load_trace_by_run_id(run_id, trace_dir)
|
|
343
|
+
if current is None or current.get("status") != "resuming":
|
|
344
|
+
return "unchanged"
|
|
345
|
+
child_run_id = str(current.get("resumed_to_run_id", ""))
|
|
346
|
+
child = (
|
|
347
|
+
load_trace_by_run_id(child_run_id, trace_dir)
|
|
348
|
+
if child_run_id
|
|
349
|
+
else None
|
|
350
|
+
)
|
|
351
|
+
reconciled_at = now.isoformat()
|
|
352
|
+
current["reconciled_at"] = reconciled_at
|
|
353
|
+
current["reconciled_by_runtime_instance_id"] = current_instance_id
|
|
354
|
+
if child is None:
|
|
355
|
+
current["status"] = "requires_approval"
|
|
356
|
+
current["resume_recovery"] = "reopened_before_child_initialization"
|
|
357
|
+
_clear_resume_claim(current, clear_child=True)
|
|
358
|
+
persist_trace(current, trace_dir)
|
|
359
|
+
return "reopened"
|
|
360
|
+
current["status"] = "resumed"
|
|
361
|
+
current["completed_at"] = reconciled_at
|
|
362
|
+
current["resumed_at"] = reconciled_at
|
|
363
|
+
current["resume_recovery"] = "approval_consumed_after_owner_loss"
|
|
364
|
+
current.pop("pending_approval", None)
|
|
365
|
+
current.pop("resume_claim_id", None)
|
|
366
|
+
current.pop("resume_claimed_at", None)
|
|
367
|
+
persist_trace(current, trace_dir)
|
|
368
|
+
return "completed"
|
|
369
|
+
except (OSError, ValueError) as exc:
|
|
370
|
+
return f"error:{run_id}: {exc}"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class _TraceReconcileLock:
|
|
374
|
+
def __init__(
|
|
375
|
+
self,
|
|
376
|
+
trace_dir: str,
|
|
377
|
+
run_id: str,
|
|
378
|
+
*,
|
|
379
|
+
instance_id: str,
|
|
380
|
+
stale_after_seconds: float,
|
|
381
|
+
now: datetime,
|
|
382
|
+
) -> None:
|
|
383
|
+
self.path = Path(trace_dir) / f".{safe_trace_file_stem(run_id)}.reconcile.lock"
|
|
384
|
+
self.instance_id = instance_id
|
|
385
|
+
self.stale_after_seconds = stale_after_seconds
|
|
386
|
+
self.now = now
|
|
387
|
+
self.fd: Optional[int] = None
|
|
388
|
+
|
|
389
|
+
def __enter__(self) -> bool:
|
|
390
|
+
if self._create():
|
|
391
|
+
return True
|
|
392
|
+
if not self._reclaim_stale():
|
|
393
|
+
return False
|
|
394
|
+
return self._create()
|
|
395
|
+
|
|
396
|
+
def _create(self) -> bool:
|
|
397
|
+
try:
|
|
398
|
+
self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
399
|
+
except FileExistsError:
|
|
400
|
+
return False
|
|
401
|
+
try:
|
|
402
|
+
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
403
|
+
payload = json.dumps(
|
|
404
|
+
{
|
|
405
|
+
"runtime_instance_id": self.instance_id,
|
|
406
|
+
"created_at": self.now.isoformat(),
|
|
407
|
+
},
|
|
408
|
+
sort_keys=True,
|
|
409
|
+
)
|
|
410
|
+
os.write(self.fd, f"{payload}\n".encode("utf-8"))
|
|
411
|
+
os.fsync(self.fd)
|
|
412
|
+
except Exception:
|
|
413
|
+
os.close(self.fd)
|
|
414
|
+
self.fd = None
|
|
415
|
+
self.path.unlink(missing_ok=True)
|
|
416
|
+
raise
|
|
417
|
+
return True
|
|
418
|
+
|
|
419
|
+
def _reclaim_stale(self) -> bool:
|
|
420
|
+
flags = os.O_RDWR
|
|
421
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
422
|
+
flags |= os.O_NOFOLLOW
|
|
423
|
+
try:
|
|
424
|
+
existing_fd = os.open(self.path, flags)
|
|
425
|
+
except FileNotFoundError:
|
|
426
|
+
return True
|
|
427
|
+
try:
|
|
428
|
+
stat_result = os.fstat(existing_fd)
|
|
429
|
+
if not self._existing_lock_is_stale(existing_fd, stat_result.st_mtime):
|
|
430
|
+
return False
|
|
431
|
+
try:
|
|
432
|
+
fcntl.flock(existing_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
433
|
+
except BlockingIOError:
|
|
434
|
+
return False
|
|
435
|
+
locked_stat = os.fstat(existing_fd)
|
|
436
|
+
if (
|
|
437
|
+
locked_stat.st_dev != stat_result.st_dev
|
|
438
|
+
or locked_stat.st_ino != stat_result.st_ino
|
|
439
|
+
or not self._existing_lock_is_stale(
|
|
440
|
+
existing_fd,
|
|
441
|
+
locked_stat.st_mtime,
|
|
442
|
+
)
|
|
443
|
+
):
|
|
444
|
+
return False
|
|
445
|
+
try:
|
|
446
|
+
current_stat = os.stat(self.path, follow_symlinks=False)
|
|
447
|
+
except FileNotFoundError:
|
|
448
|
+
return True
|
|
449
|
+
if (
|
|
450
|
+
current_stat.st_dev != locked_stat.st_dev
|
|
451
|
+
or current_stat.st_ino != locked_stat.st_ino
|
|
452
|
+
):
|
|
453
|
+
return False
|
|
454
|
+
self.path.unlink()
|
|
455
|
+
return True
|
|
456
|
+
finally:
|
|
457
|
+
os.close(existing_fd)
|
|
458
|
+
|
|
459
|
+
def _existing_lock_is_stale(self, existing_fd: int, modified_at: float) -> bool:
|
|
460
|
+
try:
|
|
461
|
+
os.lseek(existing_fd, 0, os.SEEK_SET)
|
|
462
|
+
payload = json.loads(os.read(existing_fd, 4096).decode("utf-8"))
|
|
463
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
464
|
+
payload = {}
|
|
465
|
+
if not isinstance(payload, dict):
|
|
466
|
+
payload = {}
|
|
467
|
+
created_at = _parse_timestamp(payload.get("created_at"))
|
|
468
|
+
lock_time = created_at or datetime.fromtimestamp(modified_at, timezone.utc)
|
|
469
|
+
return (self.now - lock_time).total_seconds() > self.stale_after_seconds
|
|
470
|
+
|
|
471
|
+
def __exit__(self, _exc_type, _exc, _traceback) -> None:
|
|
472
|
+
if self.fd is None:
|
|
473
|
+
return
|
|
474
|
+
try:
|
|
475
|
+
stat_result = os.fstat(self.fd)
|
|
476
|
+
try:
|
|
477
|
+
current_stat = os.stat(self.path, follow_symlinks=False)
|
|
478
|
+
except FileNotFoundError:
|
|
479
|
+
current_stat = None
|
|
480
|
+
if (
|
|
481
|
+
current_stat is not None
|
|
482
|
+
and current_stat.st_dev == stat_result.st_dev
|
|
483
|
+
and current_stat.st_ino == stat_result.st_ino
|
|
484
|
+
):
|
|
485
|
+
self.path.unlink()
|
|
486
|
+
finally:
|
|
487
|
+
os.close(self.fd)
|
|
488
|
+
self.fd = None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _record_outcome(summary: Dict[str, Any], outcome: str, success_key: str) -> None:
|
|
492
|
+
if outcome == "recovered":
|
|
493
|
+
summary[success_key] += 1
|
|
494
|
+
elif outcome == "locked":
|
|
495
|
+
summary["skipped_locked"] += 1
|
|
496
|
+
elif outcome.startswith("error:"):
|
|
497
|
+
summary["errors"].append(outcome.removeprefix("error:"))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _clear_resume_claim(trace: Dict[str, Any], *, clear_child: bool) -> None:
|
|
501
|
+
for key in (
|
|
502
|
+
"resume_claim_id",
|
|
503
|
+
"resume_claimed_at",
|
|
504
|
+
"resume_runtime_instance_id",
|
|
505
|
+
"resumed_by_auth_subject",
|
|
506
|
+
"approved_by_auth_subject",
|
|
507
|
+
):
|
|
508
|
+
trace.pop(key, None)
|
|
509
|
+
if clear_child:
|
|
510
|
+
trace.pop("resumed_to_run_id", None)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _append_recovery_event(trace: Dict[str, Any], completed_at: str) -> None:
|
|
514
|
+
events = trace.get("events")
|
|
515
|
+
if not isinstance(events, list):
|
|
516
|
+
events = []
|
|
517
|
+
trace["events"] = events
|
|
518
|
+
events.append(
|
|
519
|
+
{
|
|
520
|
+
"node": "runtime",
|
|
521
|
+
"status": "failed",
|
|
522
|
+
"started_at": completed_at,
|
|
523
|
+
"completed_at": completed_at,
|
|
524
|
+
"duration_seconds": "0.0000",
|
|
525
|
+
"error_code": str(
|
|
526
|
+
trace.get("error_code", RUNTIME_INTERRUPTED_ERROR_CODE)
|
|
527
|
+
),
|
|
528
|
+
"error": str(
|
|
529
|
+
trace.get("error", "runtime worker owner heartbeat expired")
|
|
530
|
+
),
|
|
531
|
+
}
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _refresh_duration(trace: Dict[str, Any], now: datetime) -> None:
|
|
536
|
+
started_at = _parse_timestamp(trace.get("started_at"))
|
|
537
|
+
if started_at is not None:
|
|
538
|
+
trace["duration_seconds"] = f"{max(0.0, (now - started_at).total_seconds()):.4f}"
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _lease_path(trace_dir: str, instance_id: str) -> Path:
|
|
542
|
+
return Path(trace_dir) / _INSTANCE_DIRECTORY / f"{safe_trace_file_stem(instance_id)}.json"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _persist_private_json(path: Path, payload: Dict[str, Any]) -> None:
|
|
546
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
547
|
+
os.chmod(path.parent, 0o700)
|
|
548
|
+
temporary_path = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
|
|
549
|
+
try:
|
|
550
|
+
fd = os.open(temporary_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
551
|
+
with os.fdopen(fd, "w", encoding="utf-8") as output:
|
|
552
|
+
json.dump(payload, output, sort_keys=True)
|
|
553
|
+
output.write("\n")
|
|
554
|
+
output.flush()
|
|
555
|
+
os.fsync(output.fileno())
|
|
556
|
+
os.replace(temporary_path, path)
|
|
557
|
+
os.chmod(path, 0o600)
|
|
558
|
+
finally:
|
|
559
|
+
temporary_path.unlink(missing_ok=True)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _parse_timestamp(value: Any) -> Optional[datetime]:
|
|
563
|
+
if not isinstance(value, str) or not value.strip():
|
|
564
|
+
return None
|
|
565
|
+
try:
|
|
566
|
+
parsed = datetime.fromisoformat(value)
|
|
567
|
+
except ValueError:
|
|
568
|
+
return None
|
|
569
|
+
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _utc_timestamp() -> str:
|
|
573
|
+
return datetime.now(timezone.utc).isoformat()
|