feat(companion): Promote companions from STARTING to RUNNING after startsecs

Add promote_running to CompanionManager: scans STARTING companions and moves
any that have stayed alive at least their startsecs window to RUNNING, logging
the pid and returning the promoted ones. Companions that die inside the window
are left to reaping.

Add tests for promotion after the window, too-early no-op, and non-STARTING.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tanmoy Sarkar 2026-06-09 17:52:25 +05:30
parent bd8a91f656
commit 84d69c46fd
3 changed files with 49 additions and 1 deletions

View File

@ -675,7 +675,7 @@ No per-companion logic in Arbiter.
- [x] Apply `cwd` and `env` before target.
- [x] Redirect `stdout` and `stderr`.
- [x] Reap exited companion processes.
- [ ] Implement `STARTING -> RUNNING` using `startsecs`.
- [x] Implement `STARTING -> RUNNING` using `startsecs`.
- [ ] Implement `BACKOFF` with fixed `companion_restart_delay`.
- [ ] Implement `start_process`.
- [ ] Implement `stop_process`.

View File

@ -84,6 +84,24 @@ class CompanionManager:
reaped.append(proc)
return reaped
def promote_running(self, now: float = None) -> list:
"""Move companions that survived ``startsecs`` from STARTING to RUNNING.
A freshly spawned companion starts in STARTING. If it stays alive for
its ``startsecs`` window it is considered up and becomes RUNNING; if it
dies first, reaping handles it instead. Returns the promoted ones.
"""
now = now or time.time()
promoted = []
for proc in self.processes.values():
if proc.state != State.STARTING or proc.started_at is None:
continue
if now - proc.started_at >= proc.config.startsecs:
proc.state = State.RUNNING
self.log.info("companion %s running (pid %s)", proc.name, proc.pid)
promoted.append(proc)
return promoted
def _process_by_pid(self, pid: int):
for proc in self.processes.values():
if proc.pid == pid:

View File

@ -125,6 +125,36 @@ def test_reap_no_children():
assert mgr.reap_processes() == []
def test_promote_running_after_startsecs():
mgr = make_manager("rq")
proc = mgr.processes["rq"]
proc.config.startsecs = 1
proc.state = State.STARTING
proc.started_at = 100.0
promoted = mgr.promote_running(now=101.5)
assert promoted == [proc]
assert proc.state == State.RUNNING
def test_promote_running_too_early():
mgr = make_manager("rq")
proc = mgr.processes["rq"]
proc.config.startsecs = 5
proc.state = State.STARTING
proc.started_at = 100.0
assert mgr.promote_running(now=102.0) == []
assert proc.state == State.STARTING
def test_promote_running_ignores_non_starting():
mgr = make_manager("rq")
proc = mgr.processes["rq"]
proc.state = State.BACKOFF
proc.started_at = 100.0
assert mgr.promote_running(now=999.0) == []
assert proc.state == State.BACKOFF
def test_spawn_parent_records_pid_and_starting():
mgr = make_manager("rq")
proc = mgr.processes["rq"]