@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.
Files changed (117) hide show
  1. package/README.md +353 -0
  2. package/npm/bin/kagent-serve.js +6 -0
  3. package/npm/bin/kagent.js +6 -0
  4. package/npm/lib/App.js +524 -0
  5. package/npm/lib/app-state.js +224 -0
  6. package/npm/lib/approval-choice.js +25 -0
  7. package/npm/lib/commands.js +59 -0
  8. package/npm/lib/editor.js +188 -0
  9. package/npm/lib/ink-runner.js +41 -0
  10. package/npm/lib/kagent-home.js +39 -0
  11. package/npm/lib/launcher.js +221 -0
  12. package/npm/lib/protocol.js +33 -0
  13. package/npm/lib/provider-setup.js +139 -0
  14. package/npm/lib/python-runner.js +892 -0
  15. package/npm/lib/runtime-client.js +390 -0
  16. package/npm/lib/terminal-input.js +127 -0
  17. package/npm/lib/terminal-text.js +19 -0
  18. package/npm/lib/terminal-width.js +14 -0
  19. package/npm/lib/transcript.js +227 -0
  20. package/npm/lib/ui-components.js +247 -0
  21. package/npm/lib/update-manager.js +334 -0
  22. package/package.json +39 -0
  23. package/pyproject.toml +55 -0
  24. package/src/kagent/__init__.py +90 -0
  25. package/src/kagent/cli/__init__.py +5 -0
  26. package/src/kagent/cli/__main__.py +6 -0
  27. package/src/kagent/cli/commands.py +112 -0
  28. package/src/kagent/cli/conversation.py +127 -0
  29. package/src/kagent/cli/interactive.py +841 -0
  30. package/src/kagent/cli/main.py +685 -0
  31. package/src/kagent/cli/memory.py +460 -0
  32. package/src/kagent/cli/pending_approval.py +169 -0
  33. package/src/kagent/cli/provider.py +27 -0
  34. package/src/kagent/cli/session_commands.py +401 -0
  35. package/src/kagent/cli/stdio_runtime.py +784 -0
  36. package/src/kagent/cli/trace.py +67 -0
  37. package/src/kagent/cli/ui.py +931 -0
  38. package/src/kagent/core/__init__.py +0 -0
  39. package/src/kagent/core/agent.py +296 -0
  40. package/src/kagent/core/faults.py +11 -0
  41. package/src/kagent/core/invariants.py +47 -0
  42. package/src/kagent/core/normalization.py +81 -0
  43. package/src/kagent/core/planning.py +48 -0
  44. package/src/kagent/core/state.py +73 -0
  45. package/src/kagent/core/summary.py +64 -0
  46. package/src/kagent/core/tools.py +196 -0
  47. package/src/kagent/core/trace.py +57 -0
  48. package/src/kagent/eval/__init__.py +11 -0
  49. package/src/kagent/eval/cases.py +229 -0
  50. package/src/kagent/eval/evaluator.py +216 -0
  51. package/src/kagent/integrations/__init__.py +3 -0
  52. package/src/kagent/integrations/audit.py +95 -0
  53. package/src/kagent/integrations/backends.py +131 -0
  54. package/src/kagent/integrations/memory.py +301 -0
  55. package/src/kagent/ops/__init__.py +0 -0
  56. package/src/kagent/ops/batch.py +156 -0
  57. package/src/kagent/ops/doctor.py +255 -0
  58. package/src/kagent/ops/metrics.py +214 -0
  59. package/src/kagent/ops/release_evidence.py +877 -0
  60. package/src/kagent/ops/release_manifest.py +142 -0
  61. package/src/kagent/ops/trace_replay.py +285 -0
  62. package/src/kagent/providers/__init__.py +7 -0
  63. package/src/kagent/providers/embeddings.py +187 -0
  64. package/src/kagent/providers/llm.py +770 -0
  65. package/src/kagent/py.typed +1 -0
  66. package/src/kagent/runtime/__init__.py +28 -0
  67. package/src/kagent/runtime/action_graph.py +543 -0
  68. package/src/kagent/runtime/agent.py +2089 -0
  69. package/src/kagent/runtime/approval.py +64 -0
  70. package/src/kagent/runtime/cancellation.py +64 -0
  71. package/src/kagent/runtime/checkpoint_state.py +146 -0
  72. package/src/kagent/runtime/checkpoint_storage.py +270 -0
  73. package/src/kagent/runtime/context.py +65 -0
  74. package/src/kagent/runtime/file_transaction.py +195 -0
  75. package/src/kagent/runtime/hooks.py +74 -0
  76. package/src/kagent/runtime/metadata.py +116 -0
  77. package/src/kagent/runtime/patch_checkpoints.py +385 -0
  78. package/src/kagent/runtime/policy.py +50 -0
  79. package/src/kagent/runtime/presentation.py +205 -0
  80. package/src/kagent/runtime/redaction.py +130 -0
  81. package/src/kagent/runtime/sandbox.py +331 -0
  82. package/src/kagent/runtime/skills.py +66 -0
  83. package/src/kagent/runtime/steering.py +56 -0
  84. package/src/kagent/runtime/steps.py +255 -0
  85. package/src/kagent/runtime/task_state.py +40 -0
  86. package/src/kagent/runtime/tools.py +3532 -0
  87. package/src/kagent/runtime/types.py +240 -0
  88. package/src/kagent/runtime/workspace.py +597 -0
  89. package/src/kagent/service/__init__.py +26 -0
  90. package/src/kagent/service/__main__.py +6 -0
  91. package/src/kagent/service/active_runs.py +178 -0
  92. package/src/kagent/service/cli.py +737 -0
  93. package/src/kagent/service/contract.py +2571 -0
  94. package/src/kagent/service/errors.py +71 -0
  95. package/src/kagent/service/idempotency.py +584 -0
  96. package/src/kagent/service/router.py +884 -0
  97. package/src/kagent/service/run.py +150 -0
  98. package/src/kagent/service/runtime.py +1915 -0
  99. package/src/kagent/service/runtime_approval.py +20 -0
  100. package/src/kagent/service/runtime_cancel.py +192 -0
  101. package/src/kagent/service/runtime_lifecycle.py +229 -0
  102. package/src/kagent/service/runtime_metadata.py +21 -0
  103. package/src/kagent/service/runtime_policy.py +115 -0
  104. package/src/kagent/service/runtime_recovery.py +573 -0
  105. package/src/kagent/service/runtime_resume.py +382 -0
  106. package/src/kagent/service/runtime_resume_claim.py +116 -0
  107. package/src/kagent/service/runtime_run.py +350 -0
  108. package/src/kagent/service/runtime_status.py +2007 -0
  109. package/src/kagent/service/safety.py +139 -0
  110. package/src/kagent/service/server.py +114 -0
  111. package/src/kagent/service/status.py +233 -0
  112. package/src/kagent/service/trace_store.py +322 -0
  113. package/src/kagent/service/transport.py +24 -0
  114. package/src/kagent/utils/__init__.py +0 -0
  115. package/src/kagent/utils/config_validation.py +21 -0
  116. package/src/kagent/utils/json_output.py +23 -0
  117. 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()