agentic-dev-loop 1.0.0a50__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.0a50/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a51}/PKG-INFO +1 -1
  2. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/pyproject.toml +1 -1
  3. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/graph.py +16 -2
  4. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/viz/server.py +48 -12
  5. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51/src/agentic_dev_loop.egg-info}/PKG-INFO +1 -1
  6. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_graph.py +20 -0
  7. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_viz.py +113 -6
  8. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/LICENSE +0 -0
  9. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/README.md +0 -0
  10. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/setup.cfg +0 -0
  11. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/__init__.py +0 -0
  12. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/core/__init__.py +0 -0
  13. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/core/config.py +0 -0
  14. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/core/paths.py +0 -0
  15. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/core/results.py +0 -0
  16. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/core/verdict.py +0 -0
  17. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/__init__.py +0 -0
  18. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/actions.py +0 -0
  19. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  20. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  21. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/agents.py +0 -0
  22. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  23. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/brief_coverage.py +0 -0
  24. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/brief_prompt.md +0 -0
  25. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/brief_prompt.py +0 -0
  26. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/cli.py +0 -0
  27. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/containers.py +0 -0
  28. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/deps.py +0 -0
  29. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  30. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  31. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  32. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  33. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  34. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  35. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  36. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/isolation.py +0 -0
  37. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/llm.py +0 -0
  38. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/loaders.py +0 -0
  39. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/mounts.py +0 -0
  40. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/outputs.py +0 -0
  41. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/presets.py +0 -0
  42. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/proc.py +0 -0
  43. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/registry.py +0 -0
  44. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  45. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  46. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/secrets.py +0 -0
  47. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/stages.py +0 -0
  48. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  49. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  50. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/py.typed +0 -0
  51. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/viz/__init__.py +0 -0
  52. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop/viz/__main__.py +0 -0
  53. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  54. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  55. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  56. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  57. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  58. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_028_declared_caches.py +0 -0
  59. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_029_merge_on_conflict.py +0 -0
  60. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_033_brief_step.py +0 -0
  61. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_035_finalize_nudge.py +0 -0
  62. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  63. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_041_brief_coverage.py +0 -0
  64. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_046_brief_prompt.py +0 -0
  65. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_actions.py +0 -0
  66. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_agent_error_guide.py +0 -0
  67. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_agents.py +0 -0
  68. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_aidlc.py +0 -0
  69. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_cli.py +0 -0
  70. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_config.py +0 -0
  71. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_containers.py +0 -0
  72. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_deps.py +0 -0
  73. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_dry_run.py +0 -0
  74. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_e2e_fixtures.py +0 -0
  75. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_egress_proxy.py +0 -0
  76. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_hostboundary.py +0 -0
  77. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_hostboundary_integration.py +0 -0
  78. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_isolation.py +0 -0
  79. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_isolation_integration.py +0 -0
  80. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_isolation_pbt.py +0 -0
  81. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_llm.py +0 -0
  82. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_loaders.py +0 -0
  83. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_module_contract.py +0 -0
  84. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_outputs.py +0 -0
  85. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_outputs_pbt.py +0 -0
  86. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_proc.py +0 -0
  87. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_publish.py +0 -0
  88. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_qa_prompt.py +0 -0
  89. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_registry.py +0 -0
  90. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_repo_ascii.py +0 -0
  91. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_results.py +0 -0
  92. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_sdk_compat.py +0 -0
  93. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_secrets.py +0 -0
  94. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_secrets_integration.py +0 -0
  95. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_secrets_pipeline.py +0 -0
  96. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_source_resolution.py +0 -0
  97. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_stages.py +0 -0
  98. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_step_workspace.py +0 -0
  99. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_sync_back.py +0 -0
  100. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_trigger_script.py +0 -0
  101. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_unit5_config.py +0 -0
  102. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_unit5_pbt.py +0 -0
  103. {agentic_dev_loop-1.0.0a50 → agentic_dev_loop-1.0.0a51}/tests/test_unit5_pure.py +0 -0
  104. {agentic_dev_loop-1.0.0a50 → 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.0a50
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a50"
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)
@@ -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.0a50
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
@@ -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()
@@ -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