@@ -1539,6 +1539,168 @@ def publish(self, event: dict) -> None:
15391539 finally :
15401540 cleanup ()
15411541
1542+ def test_actor_activity_thread_writes_ledger_on_state_change (self ) -> None :
1543+ """actor.activity should be written to ledger on working-state transitions."""
1544+ from cccc .daemon .serve_ops import start_actor_activity_thread
1545+ from cccc .kernel .actors import add_actor
1546+ from cccc .kernel .group import create_group , load_group
1547+ from cccc .kernel .registry import load_registry
1548+
1549+ home , cleanup = self ._with_home ()
1550+ try :
1551+ reg = load_registry ()
1552+ created = create_group (reg , title = "codex-ledger-activity" , topic = "" )
1553+ group = load_group (created .group_id )
1554+ self .assertIsNotNone (group )
1555+ add_actor (group , actor_id = "peer1" , title = "Peer 1" , runtime = "codex" , runner = "headless" ) # type: ignore[arg-type]
1556+ group .save () # type: ignore[union-attr]
1557+
1558+ # Re-load to get fresh ledger_path
1559+ group = load_group (created .group_id )
1560+ self .assertIsNotNone (group )
1561+ ledger_path = group .ledger_path # type: ignore[union-attr]
1562+
1563+ class _Broadcaster :
1564+ def publish (self , event : dict ) -> None :
1565+ pass
1566+
1567+ status_holder = {"status" : "working" , "running" : True }
1568+
1569+ class _CodexSupervisor :
1570+ @staticmethod
1571+ def get_state (group_id : str , actor_id : str ) -> dict :
1572+ return {
1573+ "group_id" : group_id ,
1574+ "actor_id" : actor_id ,
1575+ "status" : status_holder ["status" ],
1576+ "current_task_id" : "turn-1" ,
1577+ "updated_at" : "2026-04-02T10:00:00Z" ,
1578+ }
1579+
1580+ @staticmethod
1581+ def actor_running (_group_id : str , _actor_id : str ) -> bool :
1582+ return bool (status_holder .get ("running" , True ))
1583+
1584+ stop_event = threading .Event ()
1585+ thread = start_actor_activity_thread (
1586+ stop_event = stop_event ,
1587+ home = Path (home ),
1588+ pty_supervisor = object (),
1589+ headless_supervisor = object (),
1590+ codex_supervisor = _CodexSupervisor (),
1591+ event_broadcaster = _Broadcaster (),
1592+ load_group = load_group ,
1593+ interval_seconds = 1.0 ,
1594+ )
1595+ try :
1596+ # First tick runs immediately: new actor → state_changed → writes to ledger
1597+ time .sleep (0.25 )
1598+ # Verify ledger has actor.activity
1599+ import json
1600+ lines = ledger_path .read_text (encoding = "utf-8" ).strip ().split ("\n " )
1601+ activity_lines = [json .loads (line ) for line in lines if '"actor.activity"' in line ]
1602+ self .assertTrue (activity_lines , "First tick should write actor.activity to ledger" )
1603+ self .assertEqual (activity_lines [- 1 ]["data" ]["actors" ][0 ]["effective_working_state" ], "working" )
1604+
1605+ initial_count = len (activity_lines )
1606+ # Wait another tick (>1s interval) — no state change → no new ledger write
1607+ time .sleep (1.3 )
1608+ lines2 = ledger_path .read_text (encoding = "utf-8" ).strip ().split ("\n " )
1609+ activity_lines2 = [json .loads (line ) for line in lines2 if '"actor.activity"' in line ]
1610+ self .assertEqual (len (activity_lines2 ), initial_count , "No state change should not add ledger entries" )
1611+
1612+ # Change state: working → idle → should write to ledger on next tick
1613+ status_holder ["status" ] = "idle"
1614+ time .sleep (1.3 )
1615+ lines3 = ledger_path .read_text (encoding = "utf-8" ).strip ().split ("\n " )
1616+ activity_lines3 = [json .loads (line ) for line in lines3 if '"actor.activity"' in line ]
1617+ self .assertGreater (len (activity_lines3 ), initial_count , "State change should add ledger entry" )
1618+ self .assertEqual (activity_lines3 [- 1 ]["data" ]["actors" ][0 ]["effective_working_state" ], "idle" )
1619+
1620+ # Simulate actor stopping (actor_running returns False)
1621+ idle_count = len (activity_lines3 )
1622+ status_holder ["running" ] = False
1623+ time .sleep (1.3 )
1624+ lines4 = ledger_path .read_text (encoding = "utf-8" ).strip ().split ("\n " )
1625+ activity_lines4 = [json .loads (line ) for line in lines4 if '"actor.activity"' in line ]
1626+ self .assertGreater (len (activity_lines4 ), idle_count , "Actor stop should add ledger entry" )
1627+ last_event = activity_lines4 [- 1 ]
1628+ stopped_actors = [a for a in last_event ["data" ]["actors" ] if a ["id" ] == "peer1" ]
1629+ self .assertEqual (len (stopped_actors ), 1 , "Stopped actor should appear in event" )
1630+ self .assertEqual (stopped_actors [0 ]["effective_working_state" ], "stopped" )
1631+ self .assertFalse (stopped_actors [0 ]["running" ])
1632+ finally :
1633+ stop_event .set ()
1634+ thread .join (timeout = 1.0 )
1635+ finally :
1636+ cleanup ()
1637+
1638+ def test_actor_activity_thread_preserves_runner_on_stopped_entry (self ) -> None :
1639+ from cccc .daemon .serve_ops import start_actor_activity_thread
1640+ from cccc .kernel .actors import add_actor
1641+ from cccc .kernel .group import create_group , load_group
1642+ from cccc .kernel .registry import load_registry
1643+
1644+ home , cleanup = self ._with_home ()
1645+ try :
1646+ reg = load_registry ()
1647+ created = create_group (reg , title = "pty-ledger-activity" , topic = "" )
1648+ group = load_group (created .group_id )
1649+ self .assertIsNotNone (group )
1650+ add_actor (group , actor_id = "peer1" , title = "Peer 1" , runtime = "codex" , runner = "pty" ) # type: ignore[arg-type]
1651+ group .save () # type: ignore[union-attr]
1652+
1653+ group = load_group (created .group_id )
1654+ self .assertIsNotNone (group )
1655+ ledger_path = group .ledger_path # type: ignore[union-attr]
1656+ status_holder = {"running" : True }
1657+
1658+ class _PtySupervisor :
1659+ @staticmethod
1660+ def actor_running (_group_id : str , _actor_id : str ) -> bool :
1661+ return bool (status_holder .get ("running" , True ))
1662+
1663+ @staticmethod
1664+ def idle_seconds (* , group_id : str , actor_id : str ) -> float :
1665+ return 0.0
1666+
1667+ @staticmethod
1668+ def terminal_override (* , group_id : str , actor_id : str ):
1669+ return None
1670+
1671+ class _Broadcaster :
1672+ def publish (self , event : dict ) -> None :
1673+ pass
1674+
1675+ stop_event = threading .Event ()
1676+ thread = start_actor_activity_thread (
1677+ stop_event = stop_event ,
1678+ home = Path (home ),
1679+ pty_supervisor = _PtySupervisor (),
1680+ headless_supervisor = object (),
1681+ codex_supervisor = object (),
1682+ event_broadcaster = _Broadcaster (),
1683+ load_group = load_group ,
1684+ interval_seconds = 1.0 ,
1685+ )
1686+ try :
1687+ time .sleep (0.25 )
1688+ status_holder ["running" ] = False
1689+ time .sleep (1.3 )
1690+
1691+ lines = ledger_path .read_text (encoding = "utf-8" ).strip ().split ("\n " )
1692+ activity_lines = [json .loads (line ) for line in lines if '"actor.activity"' in line ]
1693+ self .assertTrue (activity_lines , "Actor stop should write actor.activity to ledger" )
1694+ stopped_actors = [a for a in activity_lines [- 1 ]["data" ]["actors" ] if a ["id" ] == "peer1" ]
1695+ self .assertEqual (len (stopped_actors ), 1 )
1696+ self .assertEqual (stopped_actors [0 ]["effective_working_state" ], "stopped" )
1697+ self .assertEqual (stopped_actors [0 ]["runner_effective" ], "pty" )
1698+ finally :
1699+ stop_event .set ()
1700+ thread .join (timeout = 1.0 )
1701+ finally :
1702+ cleanup ()
1703+
15421704 def test_codex_session_persists_headless_state_file (self ) -> None :
15431705 from cccc .daemon .codex_app_sessions import CodexAppSession
15441706 from cccc .daemon .runner_state_ops import headless_state_path
0 commit comments