agentic-dev-loop 1.0.0a49__tar.gz → 1.0.0a51__tar.gz

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.

Potentially problematic release.


This version of agentic-dev-loop might be problematic. Click here for more details.

Files changed (104) hide show
  1. {agentic_dev_loop-1.0.0a49/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a51}/PKG-INFO +5 -2
  2. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/README.md +4 -1
  3. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/graph.py +16 -2
  5. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/isolation.py +35 -16
  6. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/viz/server.py +48 -12
  7. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51/src/agentic_dev_loop.egg-info}/PKG-INFO +5 -2
  8. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_containers.py +5 -1
  9. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_graph.py +20 -0
  10. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_isolation.py +69 -7
  11. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_viz.py +113 -6
  12. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/LICENSE +0 -0
  13. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/setup.cfg +0 -0
  14. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/__init__.py +0 -0
  15. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/core/__init__.py +0 -0
  16. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/core/config.py +0 -0
  17. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/core/paths.py +0 -0
  18. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/core/results.py +0 -0
  19. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/core/verdict.py +0 -0
  20. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/__init__.py +0 -0
  21. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/actions.py +0 -0
  22. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  23. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  24. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/agents.py +0 -0
  25. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  26. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/brief_coverage.py +0 -0
  27. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/brief_prompt.md +0 -0
  28. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/brief_prompt.py +0 -0
  29. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/cli.py +0 -0
  30. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/containers.py +0 -0
  31. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/deps.py +0 -0
  32. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  33. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  34. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  35. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  36. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  37. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  38. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  39. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/llm.py +0 -0
  40. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/loaders.py +0 -0
  41. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/mounts.py +0 -0
  42. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/outputs.py +0 -0
  43. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/presets.py +0 -0
  44. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/proc.py +0 -0
  45. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/registry.py +0 -0
  46. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  47. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  48. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/secrets.py +0 -0
  49. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/stages.py +0 -0
  50. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  51. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  52. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/py.typed +0 -0
  53. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/viz/__init__.py +0 -0
  54. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/viz/__main__.py +0 -0
  55. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  56. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  57. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  58. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  59. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  60. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_028_declared_caches.py +0 -0
  61. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_029_merge_on_conflict.py +0 -0
  62. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_033_brief_step.py +0 -0
  63. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_035_finalize_nudge.py +0 -0
  64. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  65. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_041_brief_coverage.py +0 -0
  66. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_046_brief_prompt.py +0 -0
  67. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_actions.py +0 -0
  68. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_agent_error_guide.py +0 -0
  69. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_agents.py +0 -0
  70. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_aidlc.py +0 -0
  71. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_cli.py +0 -0
  72. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_config.py +0 -0
  73. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_deps.py +0 -0
  74. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_dry_run.py +0 -0
  75. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_e2e_fixtures.py +0 -0
  76. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_egress_proxy.py +0 -0
  77. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_hostboundary.py +0 -0
  78. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_hostboundary_integration.py +0 -0
  79. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_isolation_integration.py +0 -0
  80. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_isolation_pbt.py +0 -0
  81. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_llm.py +0 -0
  82. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_loaders.py +0 -0
  83. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_module_contract.py +0 -0
  84. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_outputs.py +0 -0
  85. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_outputs_pbt.py +0 -0
  86. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_proc.py +0 -0
  87. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_publish.py +0 -0
  88. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_qa_prompt.py +0 -0
  89. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_registry.py +0 -0
  90. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_repo_ascii.py +0 -0
  91. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_results.py +0 -0
  92. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_sdk_compat.py +0 -0
  93. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_secrets.py +0 -0
  94. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_secrets_integration.py +0 -0
  95. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_secrets_pipeline.py +0 -0
  96. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_source_resolution.py +0 -0
  97. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_stages.py +0 -0
  98. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_step_workspace.py +0 -0
  99. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_sync_back.py +0 -0
  100. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_trigger_script.py +0 -0
  101. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_unit5_config.py +0 -0
  102. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_unit5_pbt.py +0 -0
  103. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_unit5_pure.py +0 -0
  104. {agentic_dev_loop-1.0.0a49 → agentic_dev_loop-1.0.0a51}/tests/test_verdict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a49
3
+ Version: 1.0.0a51
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -169,7 +169,10 @@ engine-owned object to its owning run and a `live`, `orphaned`,
169
169
  registry-owned orphans whose owner pid is dead (never unregistered names).
170
170
  A SIGTERM to a running pipeline still closes the sandbox (the graph's
171
171
  `finally` runs); SIGKILL is unhandleable, so its containers are reclaimed by
172
- the startup reaper on the next run or by `containers --reap`. Pass
172
+ the startup reaper on the next run or by `containers --reap`. The reaper
173
+ and `--reap` prune a registry entry only after the daemon confirms the
174
+ object is gone; a surviving object (a failed removal) keeps its entry so
175
+ the next startup retries it. Pass
173
176
  `--state-dir` to point at a custom `isolation.state_dir`.
174
177
 
175
178
  **What a run looks like** (the shipped self-loop example):
@@ -125,7 +125,10 @@ engine-owned object to its owning run and a `live`, `orphaned`,
125
125
  registry-owned orphans whose owner pid is dead (never unregistered names).
126
126
  A SIGTERM to a running pipeline still closes the sandbox (the graph's
127
127
  `finally` runs); SIGKILL is unhandleable, so its containers are reclaimed by
128
- the startup reaper on the next run or by `containers --reap`. Pass
128
+ the startup reaper on the next run or by `containers --reap`. The reaper
129
+ and `--reap` prune a registry entry only after the daemon confirms the
130
+ object is gone; a surviving object (a failed removal) keeps its entry so
131
+ the next startup retries it. Pass
129
132
  `--state-dir` to point at a custom `isolation.state_dir`.
130
133
 
131
134
  **What a run looks like** (the shipped self-loop example):
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a49"
7
+ version = "1.0.0a51"
8
8
  description = "Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -8,6 +8,7 @@ steer the retry cycle, and a SQLite checkpointer makes runs crash-resumable
8
8
  from __future__ import annotations
9
9
 
10
10
  import operator
11
+ import time
11
12
  import uuid
12
13
  import webbrowser
13
14
  from pathlib import Path
@@ -279,12 +280,14 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
279
280
  if bus is not None:
280
281
  start_state = initial if initial is not None \
281
282
  else graph.get_state(config).values
283
+ run_start = time.monotonic()
282
284
  bus.publish({
283
285
  "type": "run_started",
284
286
  "subject": start_state["subject"],
285
287
  "max_attempts": start_state["max_attempts"],
286
288
  "thread_id": thread_id,
287
289
  "attempt": start_state["attempt"],
290
+ "started_at": time.time(),
288
291
  })
289
292
  watch = viz.NodeWatch()
290
293
  capture = viz.ConsoleCapture(bus, watch)
@@ -292,6 +295,7 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
292
295
  heartbeat = viz.HeartbeatThread(watch, bus)
293
296
  heartbeat.start()
294
297
  final = None
298
+ step_starts: dict[str, float] = {}
295
299
  try:
296
300
  for part in graph.stream(initial, config,
297
301
  stream_mode=["tasks", "updates"],
@@ -299,8 +303,13 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
299
303
  for event in viz.translate(part):
300
304
  if event["type"] == "node_started":
301
305
  watch.start(event["node"])
306
+ step_starts[event["node"]] = time.monotonic()
302
307
  elif event["type"] == "node_finished":
303
308
  watch.finish(event["node"])
309
+ if event["node"] in step_starts:
310
+ event["elapsed_seconds"] = max(
311
+ 0, int(round(time.monotonic()
312
+ - step_starts[event["node"]])))
304
313
  bus.publish(event)
305
314
  final = graph.get_state(config).values
306
315
  finally:
@@ -317,8 +326,13 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
317
326
  else:
318
327
  success = False
319
328
  attempts = 0
320
- bus.publish({"type": "run_finished",
321
- "success": success, "attempts": attempts})
329
+ bus.publish({
330
+ "type": "run_finished",
331
+ "success": success,
332
+ "attempts": attempts,
333
+ "elapsed_seconds": max(
334
+ 0, int(round(time.monotonic() - run_start))),
335
+ })
322
336
  capture.detach()
323
337
  else:
324
338
  final = graph.invoke(initial, config)
@@ -1632,23 +1632,12 @@ class DockerBackend(Sandbox):
1632
1632
  _write_registry(self._iso_state_dir, remaining)
1633
1633
 
1634
1634
  def _container_gone(self, name: str) -> bool:
1635
- """True only when the daemon confirms the container no longer exists.
1636
- A nonzero daemon exit keeps the entry."""
1637
- res = self._runner.run(
1638
- ["docker", "ps", "-a", "--filter", f"name={name}",
1639
- "--format", "{{.Names}}"], timeout=30)
1640
- if res.exit_code != 0:
1641
- return False
1642
- return name not in res.stdout.splitlines()
1635
+ """Thin delegation to the module-level confirm helper (050 D2)."""
1636
+ return _container_gone(self._runner, name)
1643
1637
 
1644
1638
  def _network_gone(self, name: str) -> bool:
1645
- """True only when the daemon reports the network is missing. Any
1646
- other inspect failure (daemon unreachable) keeps the entry."""
1647
- res = self._runner.run(
1648
- ["docker", "network", "inspect", name], timeout=30)
1649
- if res.exit_code == 0:
1650
- return False
1651
- return "No such network" in f"{res.stderr} {res.stdout}"
1639
+ """Thin delegation to the module-level confirm helper (050 D2)."""
1640
+ return _network_gone(self._runner, name)
1652
1641
 
1653
1642
  # -- internal -----------------------------------------------------------
1654
1643
 
@@ -1882,6 +1871,28 @@ def _pid_alive_matches(pid: int, recorded_start: str | None) -> bool:
1882
1871
  return current == recorded_start
1883
1872
 
1884
1873
 
1874
+ def _container_gone(runner: CommandRunner, name: str) -> bool:
1875
+ """True only when the daemon confirms the container no longer exists.
1876
+ A nonzero daemon exit keeps the entry."""
1877
+ res = runner.run(
1878
+ ["docker", "ps", "-a", "--filter", f"name={name}",
1879
+ "--format", "{{.Names}}"], timeout=30)
1880
+ if res.exit_code != 0:
1881
+ return False
1882
+ return name not in res.stdout.splitlines()
1883
+
1884
+
1885
+ def _network_gone(runner: CommandRunner, name: str) -> bool:
1886
+ """True only when the daemon reports the network is missing. Any
1887
+ other inspect failure (daemon unreachable) keeps the entry."""
1888
+ res = runner.run(
1889
+ ["docker", "network", "inspect", name], timeout=30)
1890
+ if res.exit_code == 0:
1891
+ return False
1892
+ return "No such network" in f"{res.stderr} {res.stdout}"
1893
+
1894
+
1895
+
1885
1896
  def reap_orphaned_containers(state_dir: str | Path,
1886
1897
  runner: CommandRunner | None = None,
1887
1898
  *, announce: bool = True) -> list[str]:
@@ -1889,7 +1900,9 @@ def reap_orphaned_containers(state_dir: str | Path,
1889
1900
  and the per-run internal network whose owning CLI process is gone (crashed
1890
1901
  before close()). Exact by construction - only engine registry names are
1891
1902
  touched, and a live owner (pid alive with the same start time) is left
1892
- alone. Best-effort: never raises."""
1903
+ alone. A surviving object (rm failure) keeps its entry so the next
1904
+ startup reaper retries; only a daemon-confirmed absence prunes it (050
1905
+ D1). Best-effort: never raises."""
1893
1906
  runner = runner or _default_runner
1894
1907
  entries = _read_registry(Path(state_dir))
1895
1908
  if not entries:
@@ -1909,6 +1922,9 @@ def reap_orphaned_containers(state_dir: str | Path,
1909
1922
  remaining.append(entry)
1910
1923
  continue
1911
1924
  runner.run(["docker", "network", "rm", name], timeout=60)
1925
+ if not _network_gone(runner, name):
1926
+ remaining.append(entry)
1927
+ continue
1912
1928
  if announce:
1913
1929
  _announce(f"reaped orphaned sandbox network {name}")
1914
1930
  reaped.append(name)
@@ -1924,6 +1940,9 @@ def reap_orphaned_containers(state_dir: str | Path,
1924
1940
  remaining.append(entry)
1925
1941
  continue
1926
1942
  runner.run(["docker", "rm", "-f", name], timeout=60)
1943
+ if not _container_gone(runner, name):
1944
+ remaining.append(entry)
1945
+ continue
1927
1946
  if announce:
1928
1947
  _announce(f"reaped orphaned sandbox container {name}")
1929
1948
  reaped.append(name)
@@ -466,7 +466,7 @@ PAGE = """<!doctype html>
466
466
  #thread code { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
467
467
  background: #f5f5f5; padding: 2px 6px; border-radius: 4px;
468
468
  cursor: pointer; user-select: all; }
469
- #elapsed { font-size: 14px; color: #1976d2; display: none; }
469
+ #total { font-size: 14px; color: #1976d2; display: none; }
470
470
  #verdicts { display: flex; flex-wrap: wrap; gap: 6px; }
471
471
  .badge { font-size: 12px; padding: 3px 8px; border-radius: 10px;
472
472
  background: #f0f0f0; color: #333; }
@@ -477,12 +477,15 @@ PAGE = """<!doctype html>
477
477
  #stages { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex;
478
478
  flex-direction: column; gap: 8px; }
479
479
  .stage { border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; }
480
- .stage-header { display: block; width: 100%; text-align: left; cursor: pointer;
480
+ .stage-header { display: flex; align-items: baseline; width: 100%;
481
+ text-align: left; cursor: pointer;
481
482
  font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI",
482
483
  Roboto, Helvetica, Arial, sans-serif; padding: 6px 10px;
483
484
  border: 0; background: #f7f7f7; }
484
485
  .stage-glyph { display: inline-block; width: 1.1em; }
485
486
  .stage-status { margin-left: 0.4em; }
487
+ .stage-time { margin-left: auto; color: #555;
488
+ font-variant-numeric: tabular-nums; }
486
489
  .status-pending { color: #555; }
487
490
  .status-running { color: #1976d2; }
488
491
  .status-pass { color: #2e7d32; }
@@ -531,7 +534,7 @@ PAGE = """<!doctype html>
531
534
  <h2 id="subject">agentic-dev-loop pipeline</h2>
532
535
  <div id="attempt">attempt ?/?</div>
533
536
  <div id="thread">thread id: <code id="thread-id" title="click to select">\u2014</code></div>
534
- <div id="elapsed"></div>
537
+ <div id="total"></div>
535
538
  <div id="verdicts"></div>
536
539
  <div id="stages"></div>
537
540
  <ul id="run-log"></ul>
@@ -554,6 +557,10 @@ PAGE = """<!doctype html>
554
557
  var renderCount = 0;
555
558
  var mermaidFailed = !!window.__mermaidFailed;
556
559
  var pendingCompletion = null;
560
+ var stepTimes = {};
561
+ var liveElapsed = {};
562
+ var runStartWall = null;
563
+ var totalTimer = null;
557
564
  var topologyReady = false;
558
565
  var pendingEvents = [];
559
566
  var MAX_CONSOLE = 500;
@@ -602,10 +609,16 @@ PAGE = """<!doctype html>
602
609
  return m + ":" + (s < 10 ? "0" : "") + s;
603
610
  }
604
611
 
605
- function clearElapsed() {
606
- var line = el("elapsed");
607
- line.textContent = "";
608
- line.style.display = "none";
612
+ function renderTotal() {
613
+ var line = el("total");
614
+ if (runStartWall === null) {
615
+ line.textContent = "";
616
+ line.style.display = "none";
617
+ return;
618
+ }
619
+ line.textContent = "total " + mmss(Math.max(0,
620
+ Math.round(Date.now() / 1000 - runStartWall)));
621
+ line.style.display = "block";
609
622
  }
610
623
 
611
624
  function statusOf(n) {
@@ -723,6 +736,9 @@ PAGE = """<!doctype html>
723
736
  header.appendChild(glyph);
724
737
  header.appendChild(name);
725
738
  header.appendChild(status);
739
+ var time = document.createElement("span");
740
+ time.className = "stage-time";
741
+ header.appendChild(time);
726
742
 
727
743
  var console = document.createElement("pre");
728
744
  console.className = "stage-console";
@@ -768,6 +784,10 @@ PAGE = """<!doctype html>
768
784
  else if (v === "env") { suffix = " (verdict: ENV)"; }
769
785
  }
770
786
  status.textContent = (word ? " " + word : "") + suffix;
787
+ header.querySelector(".stage-time").textContent =
788
+ (word === "completed" && stepTimes[node]) ? mmss(stepTimes[node])
789
+ : (word === "running" && liveElapsed[node] !== undefined)
790
+ ? mmss(liveElapsed[node]) : "";
771
791
  header.className = "stage-header status-" + renderStatusOf(node);
772
792
  }
773
793
 
@@ -1033,6 +1053,13 @@ PAGE = """<!doctype html>
1033
1053
  switch (ev.type) {
1034
1054
  case "run_started":
1035
1055
  flushPending();
1056
+ stepTimes = {};
1057
+ liveElapsed = {};
1058
+ if (totalTimer !== null) { clearInterval(totalTimer); totalTimer = null; }
1059
+ runStartWall = (typeof ev.started_at === "number")
1060
+ ? ev.started_at : null;
1061
+ if (runStartWall !== null) { totalTimer = setInterval(renderTotal, 1000); }
1062
+ renderTotal();
1036
1063
  clearConsoles();
1037
1064
  resetVerdicts();
1038
1065
  if (ev.attempt !== undefined) {
@@ -1051,14 +1078,18 @@ PAGE = """<!doctype html>
1051
1078
  if (!nodeIds.has(ev.node)) { break; }
1052
1079
  nodeStatus[ev.node] = "running";
1053
1080
  stageWord[ev.node] = "running";
1081
+ liveElapsed = {};
1082
+ liveElapsed[ev.node] = 0;
1054
1083
  Array.from(nodeIds).forEach(function (n) {
1055
1084
  if (n === ev.node) { expandStage(n); } else { collapseStage(n); }
1056
1085
  });
1057
1086
  renderGraph();
1058
1087
  break;
1059
1088
  case "node_finished":
1060
- clearElapsed();
1061
1089
  if (!nodeIds.has(ev.node)) { break; }
1090
+ if (ev.elapsed_seconds !== undefined) {
1091
+ stepTimes[ev.node] = (stepTimes[ev.node] || 0) + ev.elapsed_seconds;
1092
+ }
1062
1093
  if (!fatalNodes.has(ev.node)) {
1063
1094
  nodeStatus[ev.node] = ev.ok ? "pass" : "fail";
1064
1095
  }
@@ -1071,9 +1102,9 @@ PAGE = """<!doctype html>
1071
1102
  renderGraph();
1072
1103
  break;
1073
1104
  case "heartbeat":
1074
- if (nodeStatus[ev.node] === "running") {
1075
- el("elapsed").textContent = ev.node + " running \u2014 " + mmss(ev.elapsed_seconds) + " elapsed";
1076
- el("elapsed").style.display = "block";
1105
+ if (nodeIds.has(ev.node) && nodeStatus[ev.node] === "running") {
1106
+ liveElapsed[ev.node] = ev.elapsed_seconds;
1107
+ renderStageHeader(ev.node);
1077
1108
  }
1078
1109
  break;
1079
1110
  case "state":
@@ -1106,7 +1137,12 @@ PAGE = """<!doctype html>
1106
1137
  updateAttempt();
1107
1138
  break;
1108
1139
  case "run_finished":
1109
- clearElapsed();
1140
+ if (totalTimer !== null) { clearInterval(totalTimer); totalTimer = null; }
1141
+ if (ev.elapsed_seconds !== undefined) {
1142
+ runStartWall = null;
1143
+ el("total").textContent = "total " + mmss(ev.elapsed_seconds);
1144
+ el("total").style.display = "block";
1145
+ }
1110
1146
  flushPending();
1111
1147
  if (!ev.success) {
1112
1148
  Object.keys(nodeStatus).forEach(function (n) {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a49
3
+ Version: 1.0.0a51
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -169,7 +169,10 @@ engine-owned object to its owning run and a `live`, `orphaned`,
169
169
  registry-owned orphans whose owner pid is dead (never unregistered names).
170
170
  A SIGTERM to a running pipeline still closes the sandbox (the graph's
171
171
  `finally` runs); SIGKILL is unhandleable, so its containers are reclaimed by
172
- the startup reaper on the next run or by `containers --reap`. Pass
172
+ the startup reaper on the next run or by `containers --reap`. The reaper
173
+ and `--reap` prune a registry entry only after the daemon confirms the
174
+ object is gone; a surviving object (a failed removal) keeps its entry so
175
+ the next startup retries it. Pass
173
176
  `--state-dir` to point at a custom `isolation.state_dir`.
174
177
 
175
178
  **What a run looks like** (the shipped self-loop example):
@@ -123,7 +123,11 @@ class TestReapReturnValue:
123
123
  _write(tmp_path / "state", [_entry(name, proc.pid, None)])
124
124
  runner = FakeRunner(ok(""))
125
125
  assert reap_orphaned_containers(tmp_path / "state", runner) == [name]
126
- assert runner.calls == [["docker", "rm", "-f", name]]
126
+ assert runner.calls == [
127
+ ["docker", "rm", "-f", name],
128
+ ["docker", "ps", "-a", "--filter", f"name={name}",
129
+ "--format", "{{.Names}}"],
130
+ ]
127
131
 
128
132
  def test_reap_live_owner_returns_empty(self, tmp_path):
129
133
  name = f"{'b' * 32}-exec"
@@ -7,6 +7,7 @@ everything else - files, git, checkpoints - is real."""
7
7
 
8
8
  import argparse
9
9
  import queue
10
+ import time
10
11
  import sqlite3
11
12
  import subprocess
12
13
  from pathlib import Path
@@ -453,6 +454,25 @@ class TestVizStream:
453
454
  assert events[-1]["success"] is True
454
455
  assert events[-1]["attempts"] == 1
455
456
 
457
+ def test_run_events_carry_timings(self, repo, fake_agents):
458
+ script_all_pass(fake_agents)
459
+ before = time.time()
460
+ bus = VizBus()
461
+ ok, attempts = run_pipeline("pipeline.yaml", args(), viz_bus=bus)
462
+ after = time.time()
463
+ assert (ok, attempts) == (True, 1)
464
+ events = drain_events(bus)
465
+ run_started = next(e for e in events if e["type"] == "run_started")
466
+ assert isinstance(run_started["started_at"], (int, float))
467
+ assert before <= run_started["started_at"] <= after
468
+ run_finished = next(e for e in events if e["type"] == "run_finished")
469
+ assert isinstance(run_finished["elapsed_seconds"], int)
470
+ assert run_finished["elapsed_seconds"] >= 0
471
+ for event in events:
472
+ if event["type"] == "node_finished":
473
+ assert isinstance(event["elapsed_seconds"], int)
474
+ assert event["elapsed_seconds"] >= 0
475
+
456
476
  def test_stream_path_failure_returns_same(self, repo, fake_agents):
457
477
  fake_agents.set("check", [("write", "ci-fix.md", "VERDICT: FAIL")])
458
478
  bus = VizBus()
@@ -2251,7 +2251,11 @@ class TestReaper:
2251
2251
  runner = FakeRunner()
2252
2252
  reaped = reap_orphaned_containers(tmp_path / "state", runner)
2253
2253
  assert reaped == [name]
2254
- assert runner.calls == [["docker", "rm", "-f", name]]
2254
+ assert runner.calls == [
2255
+ ["docker", "rm", "-f", name],
2256
+ ["docker", "ps", "-a", "--filter", f"name={name}",
2257
+ "--format", "{{.Names}}"],
2258
+ ]
2255
2259
  assert self._read(tmp_path / "state") == []
2256
2260
 
2257
2261
  def test_live_pid_left_alone(self, tmp_path):
@@ -2282,9 +2286,13 @@ class TestReaper:
2282
2286
  "run_id": "r", "name": name, "kind": "network",
2283
2287
  "pid": proc.pid, "process_start": None, "created_at": "now",
2284
2288
  }])
2285
- runner = FakeRunner()
2286
- reap_orphaned_containers(tmp_path / "state", runner)
2287
- assert runner.calls == [["docker", "network", "rm", name]]
2289
+ runner = FakeRunner(ok(""), fail(1, "No such network"))
2290
+ reaped = reap_orphaned_containers(tmp_path / "state", runner)
2291
+ assert reaped == [name]
2292
+ assert runner.calls == [
2293
+ ["docker", "network", "rm", name],
2294
+ ["docker", "network", "inspect", name],
2295
+ ]
2288
2296
  assert self._read(tmp_path / "state") == []
2289
2297
 
2290
2298
  def test_malformed_name_skipped(self, tmp_path):
@@ -2300,11 +2308,65 @@ class TestReaper:
2300
2308
  proc.wait()
2301
2309
  name = f"{'e' * 32}-exec"
2302
2310
  self._write(tmp_path / "state", [self._entry(name, proc.pid)])
2303
- runner = FakeRunner(fail(1, "No such container"))
2304
- reap_orphaned_containers(tmp_path / "state", runner)
2305
- assert runner.calls == [["docker", "rm", "-f", name]]
2311
+ runner = FakeRunner(fail(1, "No such container"), ok(""))
2312
+ reaped = reap_orphaned_containers(tmp_path / "state", runner)
2313
+ assert reaped == [name]
2314
+ assert runner.calls == [
2315
+ ["docker", "rm", "-f", name],
2316
+ ["docker", "ps", "-a", "--filter", f"name={name}",
2317
+ "--format", "{{.Names}}"],
2318
+ ]
2306
2319
  assert self._read(tmp_path / "state") == []
2307
2320
 
2321
+ def test_rm_failure_container_survives_retained(self, tmp_path):
2322
+ proc = subprocess.Popen([sys.executable, "-c", "pass"])
2323
+ proc.wait()
2324
+ name = f"{'a' * 32}-exec"
2325
+ entry = self._entry(name, proc.pid)
2326
+ self._write(tmp_path / "state", [entry])
2327
+ runner = FakeRunner(fail(1, "daemon busy"), ok(name + "\n"))
2328
+ reaped = reap_orphaned_containers(tmp_path / "state", runner)
2329
+ assert reaped == []
2330
+ assert self._read(tmp_path / "state") == [entry]
2331
+ assert runner.calls == [
2332
+ ["docker", "rm", "-f", name],
2333
+ ["docker", "ps", "-a", "--filter", f"name={name}",
2334
+ "--format", "{{.Names}}"],
2335
+ ]
2336
+
2337
+ def test_rm_failure_network_survives_retained(self, tmp_path):
2338
+ proc = subprocess.Popen([sys.executable, "-c", "pass"])
2339
+ proc.wait()
2340
+ name = f"{'a' * 32}-net"
2341
+ entry = {"run_id": "r", "name": name, "kind": "network",
2342
+ "pid": proc.pid, "process_start": None, "created_at": "now"}
2343
+ self._write(tmp_path / "state", [entry])
2344
+ runner = FakeRunner(fail(1, "daemon busy"), ok(name))
2345
+ reaped = reap_orphaned_containers(tmp_path / "state", runner)
2346
+ assert reaped == []
2347
+ assert self._read(tmp_path / "state") == [entry]
2348
+ assert runner.calls == [
2349
+ ["docker", "network", "rm", name],
2350
+ ["docker", "network", "inspect", name],
2351
+ ]
2352
+
2353
+ def test_missing_network_tolerated(self, tmp_path):
2354
+ proc = subprocess.Popen([sys.executable, "-c", "pass"])
2355
+ proc.wait()
2356
+ name = f"{'a' * 32}-net"
2357
+ entry = {"run_id": "r", "name": name, "kind": "network",
2358
+ "pid": proc.pid, "process_start": None, "created_at": "now"}
2359
+ self._write(tmp_path / "state", [entry])
2360
+ runner = FakeRunner(fail(1, "No such network"),
2361
+ fail(1, "No such network"))
2362
+ reaped = reap_orphaned_containers(tmp_path / "state", runner)
2363
+ assert reaped == [name]
2364
+ assert self._read(tmp_path / "state") == []
2365
+ assert runner.calls == [
2366
+ ["docker", "network", "rm", name],
2367
+ ["docker", "network", "inspect", name],
2368
+ ]
2369
+
2308
2370
  def test_no_registry_is_a_noop(self, tmp_path):
2309
2371
  runner = FakeRunner()
2310
2372
  reap_orphaned_containers(tmp_path / "state", runner)
@@ -764,9 +764,22 @@ class TestServer:
764
764
  assert "elapsed_seconds" in PAGE
765
765
  assert "elapsed" in PAGE
766
766
 
767
- def test_page_constant_clears_elapsed(self):
768
- assert "clearElapsed" in PAGE
769
- assert 'id="elapsed"' in PAGE
767
+ def test_page_constant_has_total(self):
768
+ assert 'id="total"' in PAGE
769
+ assert "total " in PAGE
770
+
771
+ def test_page_constant_has_step_time(self):
772
+ assert "stepTimes" in PAGE
773
+ assert ".stage-time" in PAGE
774
+ assert "stage-time" in PAGE
775
+ assert "liveElapsed" in PAGE
776
+
777
+ def test_page_constant_has_loop_total(self):
778
+ assert "renderTotal" in PAGE
779
+ assert "started_at" in PAGE
780
+ assert "setInterval" in PAGE
781
+ assert "clearInterval" in PAGE
782
+ assert "total " in PAGE
770
783
 
771
784
  def test_page_constant_buffers_sse_until_topology_ready(self):
772
785
  assert "topologyReady" in PAGE
@@ -933,7 +946,7 @@ El.prototype.querySelectorAll = function (sel) {
933
946
  };
934
947
  El.prototype.scrollIntoView = function () {};
935
948
 
936
- var STATIC_IDS = ["subject", "attempt", "thread-id", "elapsed", "verdicts",
949
+ var STATIC_IDS = ["subject", "attempt", "thread-id", "total", "verdicts",
937
950
  "stages", "run-log", "mermaid-container", "run-banner",
938
951
  "divider", "sidebar"];
939
952
  STATIC_IDS.forEach(function (id) { registry[id] = new El(id); });
@@ -961,6 +974,7 @@ var esInstance = null;
961
974
  globalThis.document = document;
962
975
  globalThis.window = window;
963
976
  var copiedText = null;
977
+ var intervalFn = null;
964
978
  // node 21+ ships a read-only `navigator` global, so defineProperty is needed
965
979
  Object.defineProperty(globalThis, "navigator", {
966
980
  configurable: true,
@@ -990,6 +1004,10 @@ globalThis.fetch = function () {
990
1004
  };
991
1005
  globalThis.requestAnimationFrame = function (fn) { fn(); };
992
1006
  globalThis.setTimeout = function () { return 0; };
1007
+ globalThis.setInterval = function (fn) { intervalFn = fn; return 1; };
1008
+ globalThis.clearInterval = function () {};
1009
+ var fakeNow = 0;
1010
+ Date.now = function () { return fakeNow; };
993
1011
  globalThis.EventSource = function (url) {
994
1012
  this.url = url;
995
1013
  this.onmessage = null;
@@ -1012,15 +1030,37 @@ function nodeClasses() {
1012
1030
  }
1013
1031
  function fire(ev) { esInstance.onmessage({ data: JSON.stringify(ev) }); }
1014
1032
 
1033
+ function stageTimes() {
1034
+ var out = {};
1035
+ ["dev", "test", "review"].forEach(function (n) {
1036
+ var section = document.getElementById("stage-" + n);
1037
+ var header = section.querySelector(".stage-header");
1038
+ var t = header.querySelector(".stage-time");
1039
+ out[n] = t ? t.textContent : null;
1040
+ });
1041
+ return out;
1042
+ }
1043
+ function totalText() {
1044
+ var total = document.getElementById("total");
1045
+ return { text: total.textContent, display: total.style.display };
1046
+ }
1047
+
1015
1048
  var snapshots = [];
1016
1049
  async function main() {
1017
1050
  // let the topology fetch microtask chain resolve so events are handled
1018
1051
  // live (synchronously inside es.onmessage) instead of buffered
1019
1052
  await new Promise(function (res) { setImmediate(res); });
1020
1053
  EVENTS.forEach(function (ev) {
1054
+ if (ev.type === "__tick") {
1055
+ fakeNow = ev.now;
1056
+ if (intervalFn) { intervalFn(); }
1057
+ return;
1058
+ }
1021
1059
  fire(ev);
1022
1060
  if (ev.type === "state") {
1023
- snapshots.push({ ev: ev, badges: badges(), classes: nodeClasses() });
1061
+ snapshots.push({ ev: ev, badges: badges(), classes: nodeClasses(),
1062
+ times: stageTimes(), total: totalText(),
1063
+ timer: intervalFn !== null });
1024
1064
  }
1025
1065
  });
1026
1066
  // exercise the copy button on the dev console
@@ -1029,7 +1069,9 @@ async function main() {
1029
1069
  copyBtn.listeners["click"]();
1030
1070
  await new Promise(function (res) { setImmediate(res); });
1031
1071
  snapshots.push({ copy: { text: copiedText,
1032
- title: copyBtn.getAttribute("title") } });
1072
+ title: copyBtn.getAttribute("title") },
1073
+ times: stageTimes(), total: totalText(),
1074
+ timer: intervalFn !== null });
1033
1075
  console.log(JSON.stringify(snapshots));
1034
1076
  }
1035
1077
  main();
@@ -1167,6 +1209,71 @@ main();
1167
1209
  assert snaps[-1]["copy"]["title"] == "Nothing to copy"
1168
1210
 
1169
1211
 
1212
+ def test_step_header_shows_accumulated_time(self, node, tmp_path):
1213
+ events = [
1214
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1215
+ "attempt": 1, "thread_id": "t", "started_at": 1000},
1216
+ {"type": "node_started", "node": "dev"},
1217
+ {"type": "node_finished", "node": "dev", "ok": True,
1218
+ "elapsed_seconds": 135},
1219
+ {"type": "state", "attempt": 1, "failed": False},
1220
+ {"type": "node_finished", "node": "dev", "ok": True,
1221
+ "elapsed_seconds": 45},
1222
+ {"type": "state", "attempt": 1, "failed": False},
1223
+ ]
1224
+ snaps = self._run_page(node, events, tmp_path)
1225
+
1226
+ assert snaps[-1]["times"]["dev"] == "3:00"
1227
+ assert snaps[-1]["times"]["test"] == ""
1228
+ assert snaps[-1]["times"]["review"] == ""
1229
+
1230
+ def test_running_stage_shows_live_heartbeat_time(self, node, tmp_path):
1231
+ events = [
1232
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1233
+ "attempt": 1, "thread_id": "t", "started_at": 1000},
1234
+ {"type": "node_started", "node": "dev"},
1235
+ {"type": "heartbeat", "node": "dev", "elapsed_seconds": 27},
1236
+ {"type": "state", "attempt": 1, "failed": False},
1237
+ {"type": "node_finished", "node": "dev", "ok": True,
1238
+ "elapsed_seconds": 28},
1239
+ {"type": "state", "attempt": 1, "failed": False},
1240
+ ]
1241
+ snaps = self._run_page(node, events, tmp_path)
1242
+
1243
+ assert snaps[0]["times"]["dev"] == "0:27"
1244
+ assert snaps[1]["times"]["dev"] == "0:28"
1245
+
1246
+ def test_loop_total_ticks_then_freezes(self, node, tmp_path):
1247
+ events = [
1248
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1249
+ "attempt": 1, "thread_id": "t", "started_at": 1000},
1250
+ {"type": "__tick", "now": 1065000},
1251
+ {"type": "state", "attempt": 1, "failed": False},
1252
+ {"type": "run_finished", "success": True, "attempts": 1,
1253
+ "elapsed_seconds": 200},
1254
+ ]
1255
+ snaps = self._run_page(node, events, tmp_path)
1256
+
1257
+ assert snaps[0]["total"]["text"] == "total 1:05"
1258
+ assert snaps[0]["total"]["display"] == "block"
1259
+ assert snaps[-1]["total"]["text"] == "total 3:20"
1260
+
1261
+ def test_loop_total_fallback_without_started_at(self, node, tmp_path):
1262
+ events = [
1263
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1264
+ "attempt": 1, "thread_id": "t"},
1265
+ {"type": "state", "attempt": 1, "failed": False},
1266
+ {"type": "run_finished", "success": True, "attempts": 1,
1267
+ "elapsed_seconds": 95},
1268
+ ]
1269
+ snaps = self._run_page(node, events, tmp_path)
1270
+
1271
+ assert snaps[0]["timer"] is False
1272
+ assert snaps[0]["total"] == {"text": "", "display": "none"}
1273
+ assert snaps[-1]["total"]["text"] == "total 1:35"
1274
+ assert snaps[-1]["total"]["display"] == "block"
1275
+
1276
+
1170
1277
  class TestDemo:
1171
1278
  def test_run_started_carries_attempt(self, monkeypatch):
1172
1279
  import viz_demo