agentic-dev-loop 0.9.1__tar.gz → 0.9.2__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.
Files changed (103) hide show
  1. {agentic_dev_loop-0.9.1/src/agentic_dev_loop.egg-info → agentic_dev_loop-0.9.2}/PKG-INFO +1 -1
  2. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/pyproject.toml +1 -1
  3. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/viz/server.py +52 -15
  4. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2/src/agentic_dev_loop.egg-info}/PKG-INFO +1 -1
  5. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_hostboundary_integration.py +6 -0
  6. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_isolation_integration.py +6 -0
  7. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_viz.py +138 -4
  8. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/LICENSE +0 -0
  9. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/README.md +0 -0
  10. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/setup.cfg +0 -0
  11. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/__init__.py +0 -0
  12. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/core/__init__.py +0 -0
  13. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/core/config.py +0 -0
  14. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/core/paths.py +0 -0
  15. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/core/results.py +0 -0
  16. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/core/verdict.py +0 -0
  17. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/__init__.py +0 -0
  18. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/actions.py +0 -0
  19. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  20. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  21. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/agents.py +0 -0
  22. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  23. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/brief_coverage.py +0 -0
  24. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/brief_prompt.md +0 -0
  25. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/brief_prompt.py +0 -0
  26. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/cli.py +0 -0
  27. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/containers.py +0 -0
  28. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/deps.py +0 -0
  29. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  30. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  31. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  32. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  33. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  34. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  35. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/graph.py +0 -0
  36. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  37. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/isolation.py +0 -0
  38. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/llm.py +0 -0
  39. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/loaders.py +0 -0
  40. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/mounts.py +0 -0
  41. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/outputs.py +0 -0
  42. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/presets.py +0 -0
  43. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/proc.py +0 -0
  44. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/registry.py +0 -0
  45. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  46. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  47. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/secrets.py +0 -0
  48. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/stages.py +0 -0
  49. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  50. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  51. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/py.typed +0 -0
  52. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/viz/__init__.py +0 -0
  53. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop/viz/__main__.py +0 -0
  54. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  55. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  56. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  57. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  58. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  59. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_029_merge_on_conflict.py +0 -0
  60. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_033_brief_step.py +0 -0
  61. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_035_finalize_nudge.py +0 -0
  62. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  63. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_041_brief_coverage.py +0 -0
  64. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_046_brief_prompt.py +0 -0
  65. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_actions.py +0 -0
  66. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_agent_error_guide.py +0 -0
  67. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_agents.py +0 -0
  68. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_aidlc.py +0 -0
  69. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_cli.py +0 -0
  70. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_config.py +0 -0
  71. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_containers.py +0 -0
  72. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_deps.py +0 -0
  73. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_dry_run.py +0 -0
  74. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_e2e_fixtures.py +0 -0
  75. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_egress_proxy.py +0 -0
  76. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_graph.py +0 -0
  77. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_hostboundary.py +0 -0
  78. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_isolation.py +0 -0
  79. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_isolation_pbt.py +0 -0
  80. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_llm.py +0 -0
  81. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_loaders.py +0 -0
  82. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_module_contract.py +0 -0
  83. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_outputs.py +0 -0
  84. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_outputs_pbt.py +0 -0
  85. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_proc.py +0 -0
  86. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_publish.py +0 -0
  87. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_qa_prompt.py +0 -0
  88. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_registry.py +0 -0
  89. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_repo_ascii.py +0 -0
  90. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_results.py +0 -0
  91. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_sdk_compat.py +0 -0
  92. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_secrets.py +0 -0
  93. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_secrets_integration.py +0 -0
  94. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_secrets_pipeline.py +0 -0
  95. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_source_resolution.py +0 -0
  96. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_stages.py +0 -0
  97. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_step_workspace.py +0 -0
  98. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_sync_back.py +0 -0
  99. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_trigger_script.py +0 -0
  100. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_unit5_config.py +0 -0
  101. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_unit5_pbt.py +0 -0
  102. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_unit5_pure.py +0 -0
  103. {agentic_dev_loop-0.9.1 → agentic_dev_loop-0.9.2}/tests/test_verdict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 0.9.1
3
+ Version: 0.9.2
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 = "0.9.1"
7
+ version = "0.9.2"
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"
@@ -456,6 +456,10 @@ PAGE = """<!doctype html>
456
456
  border-radius: 6px; margin-top: 10px; display: none; }
457
457
  .banner-success { background: #e8f5e9; color: #2e7d32; }
458
458
  .banner-failure { background: #ffebee; color: #c62828; }
459
+ #stream-banner { display: none; position: fixed; top: 0; left: 0; right: 0;
460
+ z-index: 9999; background: #b26a00; color: #ffffff;
461
+ text-align: center; padding: 8px 12px; font-size: 14px;
462
+ font-weight: 700; }
459
463
  #divider { flex: 0 0 6px; cursor: col-resize; background: #e0e0e0; }
460
464
  #divider:hover, #divider.dragging { background: #9e9e9e; }
461
465
  #sidebar { flex: 0 0 320px; border-left: 1px solid #e0e0e0; padding: 16px;
@@ -525,6 +529,7 @@ PAGE = """<!doctype html>
525
529
  onerror="window.__mermaidFailed=true"></script>
526
530
  </head>
527
531
  <body>
532
+ <div id="stream-banner">connection lost, reconnecting...</div>
528
533
  <div id="graph-panel">
529
534
  <div id="mermaid-container"><p>Loading graph…</p></div>
530
535
  <div id="run-banner"></div>
@@ -561,6 +566,9 @@ PAGE = """<!doctype html>
561
566
  var liveElapsed = {};
562
567
  var runStartWall = null;
563
568
  var totalTimer = null;
569
+ var lastEventAt = 0;
570
+ var reconnectAttempts = 0;
571
+ var runActive = false;
564
572
  var topologyReady = false;
565
573
  var pendingEvents = [];
566
574
  var MAX_CONSOLE = 500;
@@ -614,11 +622,12 @@ PAGE = """<!doctype html>
614
622
  if (runStartWall === null) {
615
623
  line.textContent = "";
616
624
  line.style.display = "none";
617
- return;
625
+ } else {
626
+ line.textContent = "total " + mmss(Math.max(0,
627
+ Math.round(Date.now() / 1000 - runStartWall)));
628
+ line.style.display = "block";
618
629
  }
619
- line.textContent = "total " + mmss(Math.max(0,
620
- Math.round(Date.now() / 1000 - runStartWall)));
621
- line.style.display = "block";
630
+ if (runActive && Date.now() - lastEventAt > 10000) { recover(); }
622
631
  }
623
632
 
624
633
  function statusOf(n) {
@@ -1055,10 +1064,11 @@ PAGE = """<!doctype html>
1055
1064
  flushPending();
1056
1065
  stepTimes = {};
1057
1066
  liveElapsed = {};
1067
+ runActive = true;
1058
1068
  if (totalTimer !== null) { clearInterval(totalTimer); totalTimer = null; }
1059
1069
  runStartWall = (typeof ev.started_at === "number")
1060
1070
  ? ev.started_at : null;
1061
- if (runStartWall !== null) { totalTimer = setInterval(renderTotal, 1000); }
1071
+ totalTimer = setInterval(renderTotal, 1000);
1062
1072
  renderTotal();
1063
1073
  clearConsoles();
1064
1074
  resetVerdicts();
@@ -1137,6 +1147,7 @@ PAGE = """<!doctype html>
1137
1147
  updateAttempt();
1138
1148
  break;
1139
1149
  case "run_finished":
1150
+ runActive = false;
1140
1151
  if (totalTimer !== null) { clearInterval(totalTimer); totalTimer = null; }
1141
1152
  if (ev.elapsed_seconds !== undefined) {
1142
1153
  runStartWall = null;
@@ -1194,16 +1205,42 @@ PAGE = """<!doctype html>
1194
1205
  }
1195
1206
  }, 5000);
1196
1207
 
1197
- var es = new EventSource("/events");
1198
- es.onmessage = function (e) {
1199
- var ev;
1200
- try { ev = JSON.parse(e.data); }
1201
- catch (err) { logLine("bad event: " + e.data); return; }
1202
- if (!topologyReady) { pendingEvents.push(ev); return; }
1203
- try { handle(ev); }
1204
- catch (err) { logLine("bad event: " + e.data); }
1205
- };
1206
- es.onerror = function () { logLine("event stream interrupted"); };
1208
+ var es = null;
1209
+
1210
+ function connectStream() {
1211
+ var stream = new EventSource("/events");
1212
+ stream.onmessage = function (e) {
1213
+ lastEventAt = Date.now();
1214
+ reconnectAttempts = 0;
1215
+ el("stream-banner").style.display = "none";
1216
+ var ev;
1217
+ try { ev = JSON.parse(e.data); }
1218
+ catch (err) { logLine("bad event: " + e.data); return; }
1219
+ if (!topologyReady) { pendingEvents.push(ev); return; }
1220
+ try { handle(ev); }
1221
+ catch (err) { logLine("bad event: " + e.data); }
1222
+ };
1223
+ stream.onerror = function () { logLine("event stream interrupted"); };
1224
+ return stream;
1225
+ }
1226
+
1227
+ es = connectStream();
1228
+
1229
+ function recover() {
1230
+ el("stream-banner").style.display = "block";
1231
+ reconnectAttempts++;
1232
+ if (reconnectAttempts > 3) {
1233
+ location.reload();
1234
+ return;
1235
+ }
1236
+ es.close();
1237
+ es = connectStream();
1238
+ lastEventAt = Date.now();
1239
+ }
1240
+
1241
+ document.addEventListener("visibilitychange", function () {
1242
+ if (runActive && Date.now() - lastEventAt > 10000) { recover(); }
1243
+ });
1207
1244
 
1208
1245
  // Grab-handle between the graph and the sidebar: drag to resize the
1209
1246
  // sidebar (and with it the console panels, which stretch with its width).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 0.9.1
3
+ Version: 0.9.2
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
@@ -46,6 +46,12 @@ def backend(tmp_path):
46
46
  pytest.skip(f"integration image {TEST_IMAGE!r} not present locally")
47
47
  ws = tmp_path / "ws"
48
48
  ws.mkdir()
49
+ # 052: sync-back resolves ignored paths via `git check-ignore` against
50
+ # the workspace, which must be a git repo (production workspaces always
51
+ # are). The synthetic fixture workspace needs the same shape.
52
+ init = SubprocessRunner().run(["git", "init", "-q", str(ws)], timeout=30)
53
+ if init.exit_code != 0:
54
+ pytest.fail(f"git init failed: {init.stderr}")
49
55
  # unit-5: every step resolves a mount set, so enforcement needs the
50
56
  # materialised .git-free export (AE-SBX-017 otherwise). Wire the export
51
57
  # the way startup_sandbox does (mirror test_isolation_integration's
@@ -192,6 +192,12 @@ class TestExportAndMountsE2E:
192
192
  (ws / "src" / "a.py").write_text("orig")
193
193
  (ws / "ro").mkdir()
194
194
  (ws / "ro" / "f").write_text("read-only")
195
+ # 052: sync-back resolves ignored paths via `git check-ignore` against
196
+ # the workspace, which must be a git repo (production workspaces
197
+ # always are). The synthetic fixture workspace needs the same shape.
198
+ init = SubprocessRunner().run(["git", "init", "-q", str(ws)], timeout=30)
199
+ if init.exit_code != 0:
200
+ pytest.fail(f"git init failed: {init.stderr}")
195
201
  export = tmp_path / "state" / "exports" / "r1"
196
202
  ws_obj = StepWorkspace.materialise(ws, [], "linux", export)
197
203
  profile = resolve_effective_profile(IsolationConfig(image=TEST_IMAGE))
@@ -854,6 +854,16 @@ class TestServer:
854
854
  assert "ev.attempt !== verdictAttempt" not in PAGE
855
855
  assert "currentVerdicts[k] = v" in PAGE
856
856
 
857
+ def test_page_constant_has_stream_watchdog(self):
858
+ assert "stream-banner" in PAGE
859
+ assert "connection lost" in PAGE
860
+ assert "visibilitychange" in PAGE
861
+ assert "location.reload" in PAGE
862
+ assert "lastEventAt" in PAGE
863
+ assert "connectStream" in PAGE
864
+ assert "recover" in PAGE
865
+ assert PAGE.count("new EventSource") == 1
866
+
857
867
 
858
868
  # The page script is real browser JS embedded in the PAGE constant. Run it
859
869
  # under node (skipped when node is absent) against a minimal DOM shim and feed
@@ -948,10 +958,12 @@ El.prototype.scrollIntoView = function () {};
948
958
 
949
959
  var STATIC_IDS = ["subject", "attempt", "thread-id", "total", "verdicts",
950
960
  "stages", "run-log", "mermaid-container", "run-banner",
951
- "divider", "sidebar"];
961
+ "stream-banner", "divider", "sidebar"];
952
962
  STATIC_IDS.forEach(function (id) { registry[id] = new El(id); });
953
963
  var elCounter = 0;
954
964
  var document = {
965
+ _listeners: {},
966
+ addEventListener: function (t, fn) { document._listeners[t] = fn; },
955
967
  getElementById: function (id) {
956
968
  if (registry[id]) { return registry[id]; }
957
969
  for (var k in registry) {
@@ -966,13 +978,18 @@ var document = {
966
978
  },
967
979
  body: new El("body")
968
980
  };
969
- var window = { __mermaidFailed: false, addEventListener: function () {},
970
- innerWidth: 1000 };
981
+ var closeCalls = 0;
982
+ var reloadCalls = 0;
983
+ var esInstances = [];
984
+ var window = { __mermaidFailed: false, innerWidth: 1000, _listeners: {} };
985
+ window.addEventListener = function (t, fn) { window._listeners[t] = fn; };
986
+ window.location = { reload: function () { reloadCalls++; } };
971
987
  var mermaidCalls = [];
972
988
  var esInstance = null;
973
989
 
974
990
  globalThis.document = document;
975
991
  globalThis.window = window;
992
+ globalThis.location = window.location;
976
993
  var copiedText = null;
977
994
  var intervalFn = null;
978
995
  // node 21+ ships a read-only `navigator` global, so defineProperty is needed
@@ -1012,6 +1029,8 @@ globalThis.EventSource = function (url) {
1012
1029
  this.url = url;
1013
1030
  this.onmessage = null;
1014
1031
  this.onerror = null;
1032
+ this.close = function () { closeCalls++; };
1033
+ esInstances.push(this);
1015
1034
  esInstance = this;
1016
1035
  };
1017
1036
 
@@ -1045,6 +1064,16 @@ function totalText() {
1045
1064
  return { text: total.textContent, display: total.style.display };
1046
1065
  }
1047
1066
 
1067
+ function watchdogState() {
1068
+ var banner = document.getElementById("stream-banner");
1069
+ return {
1070
+ instances: esInstances.length,
1071
+ closeCalls: closeCalls,
1072
+ reloadCalls: reloadCalls,
1073
+ bannerDisplay: banner ? banner.style.display : null
1074
+ };
1075
+ }
1076
+
1048
1077
  var snapshots = [];
1049
1078
  async function main() {
1050
1079
  // let the topology fetch microtask chain resolve so events are handled
@@ -1056,6 +1085,16 @@ async function main() {
1056
1085
  if (intervalFn) { intervalFn(); }
1057
1086
  return;
1058
1087
  }
1088
+ if (ev.type === "__snapshot") {
1089
+ snapshots.push({ watchdog: watchdogState() });
1090
+ return;
1091
+ }
1092
+ if (ev.type === "__visibility") {
1093
+ fakeNow = ev.now;
1094
+ var visFn = document._listeners["visibilitychange"];
1095
+ if (visFn) { visFn(); }
1096
+ return;
1097
+ }
1059
1098
  fire(ev);
1060
1099
  if (ev.type === "state") {
1061
1100
  snapshots.push({ ev: ev, badges: badges(), classes: nodeClasses(),
@@ -1268,11 +1307,106 @@ main();
1268
1307
  ]
1269
1308
  snaps = self._run_page(node, events, tmp_path)
1270
1309
 
1271
- assert snaps[0]["timer"] is False
1310
+ assert snaps[0]["timer"] is True
1272
1311
  assert snaps[0]["total"] == {"text": "", "display": "none"}
1273
1312
  assert snaps[-1]["total"]["text"] == "total 1:35"
1274
1313
  assert snaps[-1]["total"]["display"] == "block"
1275
1314
 
1315
+ def test_silence_while_run_active_recreates_stream(self, node, tmp_path):
1316
+ events = [
1317
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1318
+ "attempt": 1, "thread_id": "t", "started_at": 1000},
1319
+ {"type": "node_started", "node": "dev"},
1320
+ {"type": "heartbeat", "node": "dev", "elapsed_seconds": 5},
1321
+ {"type": "__tick", "now": 15000},
1322
+ {"type": "__snapshot"},
1323
+ ]
1324
+ snaps = self._run_page(node, events, tmp_path)
1325
+
1326
+ wd = snaps[0]["watchdog"]
1327
+ assert wd["instances"] >= 2
1328
+ assert wd["closeCalls"] == 1
1329
+ assert wd["bannerDisplay"] == "block"
1330
+ assert wd["reloadCalls"] == 0
1331
+
1332
+ def test_silence_when_idle_does_not_recreate(self, node, tmp_path):
1333
+ events = [
1334
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1335
+ "attempt": 1, "thread_id": "t"},
1336
+ {"type": "run_finished", "success": True, "attempts": 1,
1337
+ "elapsed_seconds": 60},
1338
+ {"type": "__tick", "now": 120000},
1339
+ {"type": "__snapshot"},
1340
+ ]
1341
+ snaps = self._run_page(node, events, tmp_path)
1342
+
1343
+ wd = snaps[0]["watchdog"]
1344
+ assert wd["instances"] == 1
1345
+ assert wd["bannerDisplay"] == "none"
1346
+ assert wd["reloadCalls"] == 0
1347
+
1348
+ def test_recovery_rearms_after_replay(self, node, tmp_path):
1349
+ events = [
1350
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1351
+ "attempt": 1, "thread_id": "t", "started_at": 1000},
1352
+ {"type": "node_started", "node": "dev"},
1353
+ {"type": "heartbeat", "node": "dev", "elapsed_seconds": 5},
1354
+ {"type": "__tick", "now": 15000},
1355
+ {"type": "__snapshot"},
1356
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1357
+ "attempt": 1, "thread_id": "t", "started_at": 1000},
1358
+ {"type": "node_started", "node": "dev"},
1359
+ {"type": "__tick", "now": 24000},
1360
+ {"type": "__snapshot"},
1361
+ {"type": "__tick", "now": 26000},
1362
+ {"type": "__snapshot"},
1363
+ ]
1364
+ snaps = self._run_page(node, events, tmp_path)
1365
+
1366
+ assert snaps[0]["watchdog"]["instances"] >= 2
1367
+ assert snaps[1]["watchdog"]["instances"] == 2
1368
+ assert snaps[1]["watchdog"]["bannerDisplay"] == "none"
1369
+ assert snaps[2]["watchdog"]["instances"] >= 3
1370
+ assert snaps[2]["watchdog"]["closeCalls"] >= 2
1371
+
1372
+ def test_repeated_failures_reload(self, node, tmp_path):
1373
+ events = [
1374
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1375
+ "attempt": 1, "thread_id": "t", "started_at": 1000},
1376
+ {"type": "node_started", "node": "dev"},
1377
+ {"type": "__tick", "now": 11000},
1378
+ {"type": "__tick", "now": 22000},
1379
+ {"type": "__tick", "now": 33000},
1380
+ {"type": "__tick", "now": 44000},
1381
+ {"type": "__snapshot"},
1382
+ ]
1383
+ snaps = self._run_page(node, events, tmp_path)
1384
+
1385
+ wd = snaps[0]["watchdog"]
1386
+ assert wd["reloadCalls"] == 1
1387
+ assert wd["instances"] == 4
1388
+ assert wd["closeCalls"] == 3
1389
+
1390
+ def test_visibility_return_triggers_recovery(self, node, tmp_path):
1391
+ events = [
1392
+ {"type": "run_started", "subject": "s", "max_attempts": 5,
1393
+ "attempt": 1, "thread_id": "t", "started_at": 1000},
1394
+ {"type": "node_started", "node": "dev"},
1395
+ {"type": "heartbeat", "node": "dev", "elapsed_seconds": 5},
1396
+ {"type": "__visibility", "now": 15000},
1397
+ {"type": "__snapshot"},
1398
+ {"type": "heartbeat", "node": "dev", "elapsed_seconds": 16},
1399
+ {"type": "__visibility", "now": 15000},
1400
+ {"type": "__snapshot"},
1401
+ ]
1402
+ snaps = self._run_page(node, events, tmp_path)
1403
+
1404
+ assert snaps[0]["watchdog"]["instances"] >= 2
1405
+ assert snaps[0]["watchdog"]["closeCalls"] == 1
1406
+ assert snaps[0]["watchdog"]["reloadCalls"] == 0
1407
+ assert snaps[1]["watchdog"]["instances"] == 2
1408
+ assert snaps[1]["watchdog"]["bannerDisplay"] == "none"
1409
+
1276
1410
 
1277
1411
  class TestDemo:
1278
1412
  def test_run_started_carries_attempt(self, monkeypatch):