fix(agents): a session file whose startedAt is no finite time is live (Issue #31, Mac review)

float("NaN") and a JSON NaN passed the old check, and every comparison with
NaN is false, so a live holder looked like a reused pid: the in-use gate let
the purge through and the session file was taken as stale. startedAt must
now be an int or float (not bool), finite and positive; anything else cannot
be told and counts as live. A dead pid is still stale whatever it says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-16 03:21:50 +00:00
1 parent 6e0361cf43
commit f943b0db30
2 files changed
+35 -4

No files matched your search

@@ -1,4 +1,4 @@
import os, re, json, glob, stat, time, shutil, subprocess import os, re, json, glob, math, stat, time, shutil, subprocess
from typing import Optional, Any from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names, _plausible_id from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names, _plausible_id
from lib_py.verify_session import workspace_key from lib_py.verify_session import workspace_key
@@ -77,15 +77,19 @@ def _session_file_live(data: dict, pid) -> bool:
return False return False
except OSError: except OSError:
pass pass
# startedAt is epoch ms: a finite positive number. A bool, a string, NaN
# (every comparison with it is false) or an infinity is no time at all.
started = data.get("startedAt")
if isinstance(started, bool) or not isinstance(started, (int, float)) \
or not math.isfinite(started) or started <= 0:
return True
started = started / 1000.0
try: try:
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True,
timeout=5, env=dict(os.environ, LC_ALL="C")).stdout timeout=5, env=dict(os.environ, LC_ALL="C")).stdout
lstart = time.mktime(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y")) lstart = time.mktime(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y"))
started = float(data["startedAt"]) / 1000.0
except Exception: except Exception:
return True return True
if started <= 0:
return True
# startedAt is written 1.7-3.2 s after the process starts; 2 s covers # startedAt is written 1.7-3.2 s after the process starts; 2 s covers
# lstart's one-second resolution. # lstart's one-second resolution.
return lstart <= started + 2 return lstart <= started + 2
+27
View File
@@ -447,6 +447,33 @@ def test_session_file_liveness_fails_safe(monkeypatch, live):
assert claude_mod._session_file_live({"startedAt": hour_ago}, live.pid) is True, "ps unavailable: live" assert claude_mod._session_file_live({"startedAt": hour_ago}, live.pid) is True, "ps unavailable: live"
@pytest.mark.parametrize("started", ["NaN", float("nan"), float("inf"), float("-inf"), "inf", "1760000000000",
"soon", True, False, None, [1], "<missing>"])
def test_an_unusable_started_at_means_live(live, started):
from lib_py.agents.adapters import claude as claude_mod
data = {} if started == "<missing>" else {"startedAt": started}
assert claude_mod._session_file_live(data, live.pid) is True
assert claude_mod._session_file_live(data, _dead_pid()) is False, "a dead pid holds nothing"
@pytest.mark.parametrize("raw", ["NaN", '"NaN"', "Infinity", "true"])
def test_a_live_holder_with_an_unusable_started_at_blocks_the_purge(home, tmp_path, conv, live, raw):
"""json.load turns a bare NaN or Infinity into a float that every
comparison fails, which once made a live holder look stale."""
ws = tmp_path / "ws"
ws.mkdir()
sfile, key = _session_file(home / ".claude", live.pid, conv)
sfile.write_text(f'{{"pid": {live.pid}, "sessionId": "{conv}", "cwd": "/x", "startedAt": {raw}}}')
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.conversation_in_use(conv, ctx) is True
res = _agents_cli(["in-use", "claude", conv, str(ws)], ws)
assert res.returncode == 4, res.stdout + res.stderr
assert adapter.purge_artifacts(conv, ctx) == []
assert sfile.exists() and key.exists()
def test_claude_purge_takes_the_stale_key_with_its_session_file(home, tmp_path, conv, other, live): def test_claude_purge_takes_the_stale_key_with_its_session_file(home, tmp_path, conv, other, live):
ws = tmp_path / "ws" ws = tmp_path / "ws"
ws.mkdir() ws.mkdir()