Skip to content

Commit 771eae7

Browse files
fix(C4): record a crashed session's live child pid, never signal it
Council item C4 (SIGNOFF-M2/ARBITRATION.md, residual/recorded). A server restart with the PTY/ACP child still alive was neither tested nor handled: reclaim logged and started a second agent, leaving the orphan running with no trace of it anywhere. - PTYTransport.pid / ACPTransport.pid: the forked child / subprocess pid, None before start(). Purely accessor properties -- nothing reads or acts on them beyond what follows. - build_session_state_payload gains an optional child_pid param, recorded on the claim (informational only). - Both web start paths capture acquire()'s return value and pass getattr(active_session.transport, "pid", None) through (None for StubTransport in tests, matching every other test's existing shape). - session_state.reclaim_log_message(state): the single place both start paths now build the "reclaiming a stale claim" line (replacing the duplicated inline format string R-01b required stay in exact sync), extended to name child_pid when the reclaimed claim had one. Rejected, per the council's own reasoning: killing the child on reclaim. Pid reuse could mean it now points at an unrelated process, and the child may be the user's own still-useful agent that simply outlived a crashed web server -- neither risk is worth taking. This is visibility only, feeding a future clean/doctor orphan report (R-01g, 0.2.0). Tests: PTYTransport/ACPTransport .pid unit tests, reclaim_log_message unit tests, and an end-to-end reclaim test with a tracking os.kill wrapper proving no real signal (only sig=0 liveness probes) ever reaches the recorded child pid, while the reclaim log names it. Evidence: reviews/2026-09-02-full-repo-review/evidence/M2/step-9/C4/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9482aa1 commit 771eae7

12 files changed

Lines changed: 184 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ experience may change before `1.0.0`.
186186
moment the poll runs; it now reads first and treats a vanished file as
187187
"no update" instead of checking existence then reading as two separate
188188
steps (C7).
189+
- A reclaimed session's log line now names the crashed session's agent
190+
process (if one was recorded and is still running) so it is visible
191+
that an orphaned agent may still be alive on the machine; StudyLoop
192+
still never signals it (C4).
189193
- Ending a session -- from the Web UI, `studyloop study --end`, or the study
190194
sidebar's End Session key -- no longer terminates every other study
191195
session on the machine. Each end path now closes only its own terminal

docs/architecture/session-authority.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,22 @@ session's leftover topics/parking stayed visible to whoever reclaimed the
4444
slot. A live (blocking) claim's files are never touched -- clearing is a
4545
reclaim-only side effect.
4646

47+
**C4 (council, residual, recorded not fixed):** a reclaim never checks --
48+
and never kills -- a live PTY child the crashed server's process left
49+
running (a "server restart with the agent still alive" case). Both
50+
`PTYTransport` and `ACPTransport` now expose their child's pid via a
51+
`.pid` property; the web start paths read it via `getattr(transport,
52+
"pid", None)` (`None` for `StubTransport`, used in tests) and record it on
53+
the claim as `child_pid` (`build_session_state_payload`). A reclaim's log
54+
line (`session_state.reclaim_log_message`) names it when the PREVIOUS
55+
claim had one, so a human (or a future `clean`/`doctor` orphan report,
56+
item R-01g, 0.2.0) has it to hand -- reclaim itself takes no action on it.
57+
Rejected: killing it on reclaim. The pid could have been reused by an
58+
unrelated process by the time this runs, and the child may be the user's
59+
own still-useful agent that simply outlived a crashed web server; neither
60+
risk is worth taking to tidy up an orphan that costs nothing but a stray
61+
process entry.
62+
4763
## 3. Start matrix
4864

4965
| | new start: CLI | new start: web (pty/acp) |

packages/studyloop/src/studyloop/session/start.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ def start_session(
276276
claim_blocks_cli_start,
277277
clear_session_files,
278278
read_session_state,
279+
reclaim_log_message,
279280
write_session_state,
280281
)
281282

@@ -329,11 +330,7 @@ def start_session(
329330
# C2: same rule as the web path -- a reclaimed session must not
330331
# inherit the dead session's topics/parking.
331332
clear_session_files()
332-
logger.warning(
333-
"Reclaiming stale session claim id=%s transport=%s — its owner is no longer alive",
334-
claim.get("study_session_id"),
335-
claim.get("transport", "cli"),
336-
)
333+
logger.warning(reclaim_log_message(claim))
337334

338335
# --- Create DB session ---
339336

packages/studyloop/src/studyloop/session/transports/acp.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,14 @@ def __init__(
165165
self._stderr_tail: bytes = b""
166166
self._stderr_task: asyncio.Task[None] | None = None
167167

168+
@property
169+
def pid(self) -> int | None:
170+
"""The subprocess's pid once ``start()`` has run, else ``None``.
171+
172+
Same accessor and purpose as ``PTYTransport.pid`` -- see there.
173+
"""
174+
return self._state.proc.pid if self._state is not None else None
175+
168176
# ---- AgentSessionTransport ------------------------------------------
169177

170178
async def start(self, config: SessionConfig) -> None:

packages/studyloop/src/studyloop/session/transports/pty.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,17 @@ def __init__(
225225
self._ended = False
226226
self._cancel_requested = False
227227

228+
@property
229+
def pid(self) -> int | None:
230+
"""The forked child's pid once ``start()`` has run, else ``None``.
231+
232+
C4 (council): the route layer reads this after ``acquire()``
233+
succeeds to record it on the claim as ``child_pid`` -- purely
234+
informational (for `clean`/`doctor`'s future orphan reporting,
235+
R-01g); nothing in this lane signals or kills it.
236+
"""
237+
return self._state.pid if self._state is not None else None
238+
228239
# ---- AgentSessionTransport ---------------------------------------------
229240

230241
async def start(self, config: SessionConfig) -> None:

packages/studyloop/src/studyloop/session_state.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,27 @@ def is_session_active() -> bool:
259259
return state.get("mode") != "ended"
260260

261261

262+
def reclaim_log_message(state: dict) -> str:
263+
"""The "reclaiming a stale claim" warning both start paths log,
264+
identically (R-01b required exact-wording parity between the CLI and
265+
web paths; building it once here, rather than duplicating it, keeps
266+
that guarantee mechanical instead of a promise to remember).
267+
268+
C4 (council): appends the previous owner's recorded ``child_pid`` when
269+
present, so a human (or `clean`/`doctor`, R-01g, 0.2.0) reading the log
270+
has it to hand -- reclaim itself never acts on ``child_pid`` (pid
271+
reuse makes killing an old child unsafe, and it may be the user's own
272+
still-useful agent).
273+
"""
274+
session_id = state.get("study_session_id")
275+
transport = state.get("transport", "cli")
276+
child_pid = state.get("child_pid")
277+
base = f"Reclaiming stale session claim id={session_id} transport={transport}"
278+
if child_pid is not None:
279+
base += f" child_pid={child_pid}"
280+
return base + " — its owner is no longer alive"
281+
282+
262283
def _claim_exists(state: dict) -> bool:
263284
"""Whether ``state`` names a claim that hasn't been explicitly ended."""
264285
return bool(state.get("study_session_id")) and state.get("mode") != "ended"

packages/studyloop/src/studyloop/web/routes/session/_start.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,7 @@ async def _session_conflict() -> JSONResponse | None:
143143
# here: nothing else can claim the slot until this function
144144
# returns None and the caller's own write lands).
145145
session_state.clear_session_files()
146-
logger.warning(
147-
"Reclaiming stale session claim id=%s transport=%s — its owner is no longer alive",
148-
state.get("study_session_id"),
149-
state.get("transport", "cli"),
150-
)
146+
logger.warning(session_state.reclaim_log_message(state))
151147
return None
152148

153149
session_id = str(state.get("study_session_id"))
@@ -349,7 +345,7 @@ async def _start_pty_session(
349345
factory = session_pkg._build_pty_transport(config)
350346

351347
try:
352-
await session_active.acquire(config, factory)
348+
active_session = await session_active.acquire(config, factory)
353349
except SessionAlreadyActiveError:
354350
from studyloop.history import abort_study_session
355351

@@ -391,6 +387,12 @@ async def _start_pty_session(
391387
persona_hash=persona_hash,
392388
transport="pty",
393389
now=datetime.now(UTC),
390+
# C4 (council): informational only -- getattr because
391+
# StubTransport (tests) exposes no .pid, and the real
392+
# PTYTransport's own .pid property returns None before
393+
# start() runs (never the case here, after a successful
394+
# acquire, but the getattr default is the same either way).
395+
child_pid=getattr(active_session.transport, "pid", None),
394396
)
395397
# origin distinguishes Study Session ('study') from Body Double
396398
# ('body-double') starts. Merged in here rather than in
@@ -574,7 +576,7 @@ async def _start_acp_session(
574576
factory = session_pkg._build_acp_transport(config)
575577

576578
try:
577-
await session_active.acquire(config, factory)
579+
active_session = await session_active.acquire(config, factory)
578580
except SessionAlreadyActiveError:
579581
from studyloop.history import abort_study_session
580582

@@ -615,6 +617,8 @@ async def _start_acp_session(
615617
persona_hash=persona_hash,
616618
transport="acp",
617619
now=datetime.now(UTC),
620+
# C4 (council): see the PTY path's identical comment.
621+
child_pid=getattr(active_session.transport, "pid", None),
618622
)
619623
# See PTY path: origin merged here, not in build_session_state_payload.
620624
acp_state["origin"] = origin

packages/studyloop/src/studyloop/web/services/session_start.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def build_session_state_payload(
5151
transport: TransportName,
5252
now: datetime,
5353
persona_file: str | None = None,
54+
child_pid: int | None = None,
5455
) -> dict[str, object]:
5556
"""Build the common state payload for PTY and ACP web session starts."""
5657
timestamp = now.isoformat()
@@ -88,4 +89,10 @@ def build_session_state_payload(
8889
}
8990
if persona_file is not None:
9091
payload["persona_file"] = persona_file
92+
if child_pid is not None:
93+
# C4 (council): the transport's own child process pid, purely
94+
# informational -- for `clean`/`doctor`'s future orphan reporting
95+
# (R-01g, 0.2.0). A reclaim never signals it (see
96+
# session_state.reclaim_log_message's docstring for why).
97+
payload["child_pid"] = child_pid
9198
return payload

packages/studyloop/tests/test_acp_transport.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,31 @@ async def _reset_env(monkeypatch):
8686

8787

8888
class TestStart:
89+
@pytest.mark.asyncio
90+
async def test_pid_property_reflects_the_subprocess(self, tmp_path, monkeypatch, _reset_env):
91+
"""C4: same accessor as PTYTransport, for the same reason (child_pid
92+
recorded on the claim, for future orphan reporting -- R-01g)."""
93+
monkeypatch.setenv(
94+
"STUB_ACP_INIT_RESULT",
95+
json.dumps(
96+
{
97+
"protocolVersion": 1,
98+
"agentCapabilities": {"loadSession": True},
99+
"authMethods": [],
100+
"agentInfo": {"name": "Stub ACP Agent", "version": "0.0.1"},
101+
}
102+
),
103+
)
104+
transport = ACPTransport(
105+
resolve_binary=_stub_resolve_binary,
106+
build_argv=_stub_build_argv(),
107+
)
108+
assert transport.pid is None
109+
await transport.start(_make_config(tmp_path))
110+
assert isinstance(transport.pid, int)
111+
assert transport.pid > 0
112+
await transport.end()
113+
89114
@pytest.mark.asyncio
90115
async def test_start_sends_initialize_and_session_new(self, tmp_path, monkeypatch, _reset_env):
91116
"""Happy path: initialize + session/new + Started event with the

packages/studyloop/tests/test_pty_transport.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,17 @@ async def _drain() -> None:
211211

212212
@pytest.mark.asyncio
213213
class TestHappyPath:
214+
async def test_pid_property_reflects_the_forked_child(
215+
self, transport: PTYTransport, config: SessionConfig
216+
) -> None:
217+
"""C4: the route layer needs the child's pid, after start(), to
218+
record it on the claim as child_pid -- for clean/doctor's future
219+
orphan reporting (R-01g), never to kill it on reclaim."""
220+
assert transport.pid is None
221+
await transport.start(config)
222+
assert isinstance(transport.pid, int)
223+
assert transport.pid > 0
224+
214225
async def test_start_emits_started_event_first(
215226
self, transport: PTYTransport, config: SessionConfig
216227
) -> None:

0 commit comments

Comments
 (0)